From 06e8ee96a5381c927be08630262bdd822b1ae9af Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Wed, 10 Jun 2026 23:45:40 +0800 Subject: [PATCH] feat(auth): add authentication protocol definitions and build configuration - Add TokenClaims message for JWT payload structure with user id, issuer, timestamps, and scopes - Implement IssueTokenRequest/Response for creating access and refresh tokens with TTL support - Create RefreshTokenRequest/Response for token rotation functionality - Define RevokeTokenRequest/Response with support for single token or user-wide revocation - Add VerifyTokenRequest/Response for validating JWT tokens with detailed claims information - Implement signing key distribution system with GetSigningKeysRequest/Response - Create TokenService gRPC service with IssueToken, RefreshToken, RevokeToken, VerifyToken, and GetSigningKeys methods - Add build.rs configuration to compile proto files using tonic_prost_build - Include channel, channel_settings, member, and permission protocol definitions for IM services - Generate Rust code bindings through pb/core.rs and pb/im.rs modules --- Cargo.lock | 3610 +++++++++++++++++++++++++++++ Cargo.toml | 45 + build.rs | 24 + engine/codec.rs | 239 ++ engine/heartbeat.rs | 77 + engine/mod.rs | 13 + engine/packet.rs | 151 ++ engine/polling.rs | 185 ++ engine/server.rs | 115 + engine/session.rs | 171 ++ engine/upgrade.rs | 53 + engine/websocket.rs | 254 ++ engine/webtransport.rs | 223 ++ lib.rs | 3 + main.rs | 37 + pb/core.rs | 1 + pb/im.rs | 1 + pb/mod.rs | 2 + proto/core/auth.proto | 124 + proto/core/channel.proto | 208 ++ proto/core/channel_settings.proto | 401 ++++ proto/core/member.proto | 126 + proto/core/permission.proto | 125 + socket/adapter/local.rs | 199 ++ socket/adapter/mod.rs | 99 + socket/adapter/nats.rs | 302 +++ socket/adapter/redis.rs | 344 +++ socket/message_bus/mod.rs | 31 + socket/message_bus/nats.rs | 88 + socket/message_bus/redis.rs | 99 + socket/mod.rs | 16 + socket/namespace.rs | 239 ++ socket/packet.rs | 174 ++ socket/parser.rs | 392 ++++ socket/server.rs | 301 +++ socket/session_store/memory.rs | 88 + socket/session_store/mod.rs | 41 + socket/session_store/redis.rs | 164 ++ socket/socket.rs | 72 + tests/adapter_tests.rs | 344 +++ tests/engine_io_tests.rs | 158 ++ tests/session_tests.rs | 129 ++ tests/socket_io_tests.rs | 203 ++ 43 files changed, 9671 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 build.rs create mode 100644 engine/codec.rs create mode 100644 engine/heartbeat.rs create mode 100644 engine/mod.rs create mode 100644 engine/packet.rs create mode 100644 engine/polling.rs create mode 100644 engine/server.rs create mode 100644 engine/session.rs create mode 100644 engine/upgrade.rs create mode 100644 engine/websocket.rs create mode 100644 engine/webtransport.rs create mode 100644 lib.rs create mode 100644 main.rs create mode 100644 pb/core.rs create mode 100644 pb/im.rs create mode 100644 pb/mod.rs create mode 100644 proto/core/auth.proto create mode 100644 proto/core/channel.proto create mode 100644 proto/core/channel_settings.proto create mode 100644 proto/core/member.proto create mode 100644 proto/core/permission.proto create mode 100644 socket/adapter/local.rs create mode 100644 socket/adapter/mod.rs create mode 100644 socket/adapter/nats.rs create mode 100644 socket/adapter/redis.rs create mode 100644 socket/message_bus/mod.rs create mode 100644 socket/message_bus/nats.rs create mode 100644 socket/message_bus/redis.rs create mode 100644 socket/mod.rs create mode 100644 socket/namespace.rs create mode 100644 socket/packet.rs create mode 100644 socket/parser.rs create mode 100644 socket/server.rs create mode 100644 socket/session_store/memory.rs create mode 100644 socket/session_store/mod.rs create mode 100644 socket/session_store/redis.rs create mode 100644 socket/socket.rs create mode 100644 tests/adapter_tests.rs create mode 100644 tests/engine_io_tests.rs create mode 100644 tests/session_tests.rs create mode 100644 tests/socket_io_tests.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..22049bd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3610 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.10.1", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "actix-macros", + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.4", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "actix-ws" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf53c3cdd63dd6f289980b430238f9a2f6d19f8bce8e418272e08d3da43f0f" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "futures-sink", + "tokio", + "tokio-util", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-nats" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76433c4de73442daedb3a59e991d94e85c14ebfc33db53dfcd347a21cd6ef4f8" +dependencies = [ + "base64", + "bytes", + "futures", + "memchr", + "nkeys", + "nuid", + "once_cell", + "pin-project", + "portable-atomic", + "rand 0.8.6", + "regex", + "ring", + "rustls-native-certs 0.7.3", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "signature", + "subtle", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fred" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "bytes-utils", + "float-cmp", + "fred-macros", + "futures", + "log", + "parking_lot", + "rand 0.8.6", + "redis-protocol", + "semver", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tokio-util", + "url", + "urlencoding", +] + +[[package]] +name = "fred-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.2", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.4", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imks" +version = "0.1.0" +dependencies = [ + "actix-rt", + "actix-web", + "actix-ws", + "async-nats", + "async-trait", + "base64", + "dashmap", + "fred", + "futures-util", + "prost", + "prost-types", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tonic", + "tonic-build", + "tonic-health", + "tonic-prost", + "tonic-prost-build", + "tracing", + "tracing-subscriber", + "uuid", + "walkdir", + "wtransport", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.6", + "signatory", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "octets" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redis-protocol" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1" +dependencies = [ + "bytes", + "bytes-utils", + "cookie-factory", + "crc16", + "log", + "nom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.2", + "httparse", + "rand 0.8.6", + "ring", + "rustls-native-certs 0.8.4", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2 0.4.14", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.4", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-health" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcfab99db777fba2802f0dfa861d1628d1ae916fb199d29819941f139ae85082" +dependencies = [ + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +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-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wtransport" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" +dependencies = [ + "bytes", + "pem", + "quinn", + "rcgen", + "rustls", + "rustls-native-certs 0.8.4", + "rustls-pki-types", + "sha2 0.11.0", + "socket2 0.6.4", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "url", + "wtransport-proto", + "x509-parser", +] + +[[package]] +name = "wtransport-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" +dependencies = [ + "httlib-huffman", + "octets", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0cd26dd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "imks" +version = "0.1.0" +edition = "2024" + + +[lib] +path = "lib.rs" +name = "imks" + +[[bin]] +path = "main.rs" +name = "imks" + + +[dependencies] +tonic = "0.14.6" +prost = "0.14.3" +prost-types = "0.14" +tonic-build = "0.14.6" +tonic-health = "0.14.6" +tonic-prost = "0.14.6" +tokio = { version = "1.52.3", features = ["full"] } +actix-web = { version = "4.13.0", features = [] } +actix-ws = { version = "0.4.0", features = [] } +actix-rt = "2" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +base64 = "0.22" +rand = "0.9" +wtransport = "0.7" +dashmap = "6" +thiserror = "2" +async-trait = "0.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +fred = { version = "10", features = ["subscriber-client"] } +async-nats = "0.38" +uuid = { version = "1", features = ["v4"] } +futures-util = "0.3" + + +[build-dependencies] +tonic-prost-build = "0.14.6" +walkdir = "2.5.0" \ No newline at end of file diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f54bfb1 --- /dev/null +++ b/build.rs @@ -0,0 +1,24 @@ +use std::path::Path; + +fn main() -> Result<(), Box> { + let proto_dir = Path::new("proto/core"); + let protos: Vec<_> = walkdir::WalkDir::new(proto_dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "proto")) + .map(|e| e.path().to_owned()) + .collect(); + + for proto in &protos { + println!("cargo:rerun-if-changed={}", proto.display()); + } + + let includes = vec![proto_dir.to_path_buf()]; + + tonic_prost_build::configure() + .build_server(false) + .build_client(true) + .compile_protos(&protos, &includes)?; + + Ok(()) +} diff --git a/engine/codec.rs b/engine/codec.rs new file mode 100644 index 0000000..fa96526 --- /dev/null +++ b/engine/codec.rs @@ -0,0 +1,239 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + +use crate::engine::packet::{Packet, PacketData, PacketError, PacketType}; + +const RECORD_SEPARATOR: char = '\x1e'; + +pub fn encode_packet(packet: &Packet) -> String { + let type_char = packet.packet_type as u8 + b'0'; + let type_str = type_char as char; + + match &packet.data { + PacketData::Text(s) => format!("{type_str}{s}"), + PacketData::Binary(b) => format!("{type_str}b{}", BASE64.encode(b)), + PacketData::Empty => type_str.to_string(), + } +} + +pub fn encode_packet_binary_ws(packet: &Packet) -> Vec { + let type_byte = packet.packet_type as u8 + b'0'; + + match &packet.data { + PacketData::Text(s) => { + let mut buf = Vec::with_capacity(1 + s.len()); + buf.push(type_byte); + buf.extend_from_slice(s.as_bytes()); + buf + } + PacketData::Binary(b) => { + let mut buf = Vec::with_capacity(1 + b.len()); + buf.push(type_byte); + buf.extend_from_slice(b); + buf + } + PacketData::Empty => vec![type_byte], + } +} + +pub fn decode_packet(input: &str) -> Result { + let mut chars = input.chars(); + let type_char = chars.next().ok_or(PacketError::Empty)?; + let packet_type = PacketType::try_from(type_char)?; + let rest: String = chars.collect(); + + if rest.is_empty() { + return Ok(Packet { + packet_type, + data: PacketData::Empty, + }); + } + + if let Some(b64_data) = rest.strip_prefix('b') { + let decoded = BASE64.decode(b64_data)?; + return Ok(Packet { + packet_type, + data: PacketData::Binary(decoded), + }); + } + + Ok(Packet { + packet_type, + data: PacketData::Text(rest), + }) +} + +/// Decode a WebSocket binary frame into a Packet. +/// Handles both text-encoded packets (UTF-8 payload after type byte) +/// and binary packets (raw binary payload after type byte). +pub fn decode_packet_ws(input: &[u8]) -> Result { + if input.is_empty() { + return Err(PacketError::Empty); + } + + let type_byte = input[0]; + let packet_type = PacketType::try_from(type_byte.wrapping_sub(b'0'))?; + let rest = &input[1..]; + + if rest.is_empty() { + return Ok(Packet { + packet_type, + data: PacketData::Empty, + }); + } + + // Try UTF-8 first; if it fails, treat as binary data + match String::from_utf8(rest.to_vec()) { + Ok(text) => Ok(Packet { + packet_type, + data: PacketData::Text(text), + }), + Err(_) => Ok(Packet { + packet_type, + data: PacketData::Binary(rest.to_vec()), + }), + } +} + +pub fn encode_payload(packets: &[Packet]) -> String { + packets + .iter() + .map(encode_packet) + .collect::>() + .join(&RECORD_SEPARATOR.to_string()) +} + +pub fn decode_payload(input: &str) -> Result, PacketError> { + input + .split(RECORD_SEPARATOR) + .filter(|s| !s.is_empty()) + .map(decode_packet) + .collect() +} + +pub fn encode_webtransport_header(payload_len: usize, is_binary: bool) -> Vec { + let binary_bit: u8 = if is_binary { 0x80 } else { 0x00 }; + + if payload_len <= 125 { + vec![binary_bit | (payload_len as u8)] + } else if payload_len <= 65535 { + let mut header = vec![binary_bit | 126]; + header.extend_from_slice(&(payload_len as u16).to_be_bytes()); + header + } else { + let mut header = vec![binary_bit | 127]; + header.extend_from_slice(&(payload_len as u64).to_be_bytes()); + header + } +} + +pub fn decode_webtransport_header(header: &[u8]) -> Option<(usize, bool)> { + if header.is_empty() { + return None; + } + + let first = header[0]; + let is_binary = (first & 0x80) != 0; + let len_indicator = first & 0x7f; + + if len_indicator <= 125 { + Some((len_indicator as usize, is_binary)) + } else if len_indicator == 126 { + if header.len() < 3 { + return None; + } + let len = u16::from_be_bytes([header[1], header[2]]) as usize; + Some((len, is_binary)) + } else { + if header.len() < 9 { + return None; + } + let len = u64::from_be_bytes([ + header[1], header[2], header[3], header[4], header[5], header[6], header[7], header[8], + ]) as usize; + Some((len, is_binary)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_decode_text_packet() { + let packet = Packet::message_text("hello"); + let encoded = encode_packet(&packet); + assert_eq!(encoded, "4hello"); + + let decoded = decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Message); + assert_eq!(decoded.data, PacketData::Text("hello".to_string())); + } + + #[test] + fn test_encode_decode_binary_packet() { + let packet = Packet::message_binary(vec![1, 2, 3, 4]); + let encoded = encode_packet(&packet); + assert_eq!(encoded, "4bAQIDBA=="); + + let decoded = decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Message); + assert_eq!(decoded.data, PacketData::Binary(vec![1, 2, 3, 4])); + } + + #[test] + fn test_encode_decode_payload() { + let packets = vec![ + Packet::message_text("hello"), + Packet::ping(""), + Packet::message_text("world"), + ]; + let encoded = encode_payload(&packets); + assert_eq!(encoded, "4hello\x1e2\x1e4world"); + + let decoded = decode_payload(&encoded).unwrap(); + assert_eq!(decoded.len(), 3); + assert_eq!(decoded[0].packet_type, PacketType::Message); + assert_eq!(decoded[1].packet_type, PacketType::Ping); + assert_eq!(decoded[2].packet_type, PacketType::Message); + } + + #[test] + fn test_webtransport_header() { + let header = encode_webtransport_header(6, false); + assert_eq!(header, vec![0x06]); + let (len, is_binary) = decode_webtransport_header(&header).unwrap(); + assert_eq!(len, 6); + assert!(!is_binary); + + let header = encode_webtransport_header(200, true); + assert_eq!(header.len(), 3); + let (len, is_binary) = decode_webtransport_header(&header).unwrap(); + assert_eq!(len, 200); + assert!(is_binary); + } + + #[test] + fn test_decode_packet_ws_text() { + let input = b"4hello"; + let decoded = decode_packet_ws(input).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Message); + assert_eq!(decoded.data, PacketData::Text("hello".to_string())); + } + + #[test] + fn test_decode_packet_ws_binary() { + // Type byte 4 (Message) + raw binary payload (non-UTF-8) + let input: Vec = vec![b'4', 0x80, 0xFF, 0x00, 0x01]; + let decoded = decode_packet_ws(&input).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Message); + assert_eq!(decoded.data, PacketData::Binary(vec![0x80, 0xFF, 0x00, 0x01])); + } + + #[test] + fn test_decode_packet_ws_empty() { + let input = b"4"; + let decoded = decode_packet_ws(input).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Message); + assert_eq!(decoded.data, PacketData::Empty); + } +} \ No newline at end of file diff --git a/engine/heartbeat.rs b/engine/heartbeat.rs new file mode 100644 index 0000000..e63c7b8 --- /dev/null +++ b/engine/heartbeat.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; +use std::time::Duration; + +use crate::engine::packet::Packet; +use crate::engine::session::{SessionState, SessionStore, TransportType}; + +pub struct HeartbeatManager { + store: SessionStore, + ping_interval: u64, + ping_timeout: u64, +} + +impl HeartbeatManager { + pub fn new(store: SessionStore, ping_interval: u64, ping_timeout: u64) -> Self { + Self { + store, + ping_interval, + ping_timeout, + } + } + + pub fn start(self: Arc) -> tokio::task::JoinHandle<()> { + let this = self.clone(); + tokio::spawn(async move { + this.run().await; + }) + } + + async fn run(&self) { + let mut interval = tokio::time::interval(Duration::from_millis(self.ping_interval)); + + loop { + interval.tick().await; + self.check_sessions().await; + } + } + + async fn check_sessions(&self) { + let now = std::time::Instant::now(); + let timeout_duration = Duration::from_millis(self.ping_interval + self.ping_timeout); + + let mut to_remove = Vec::new(); + + for entry in self.store.sessions.iter() { + let sid = entry.key().clone(); + let session = entry.value().clone(); + + let (state, last_ping, transport) = { + let s = session.read().await; + (s.state, s.last_ping, s.transport) + }; + + if state == SessionState::Closed { + to_remove.push(sid); + continue; + } + + if now.duration_since(last_ping) > timeout_duration { + tracing::warn!("Session {} timed out", sid); + to_remove.push(sid); + continue; + } + + // For polling sessions: buffer a ping packet for the next GET request. + // WS/WT sessions rely on their own dedicated ping tasks; the timeout + // check above already serves as the safety net for all transports. + if state == SessionState::Open && transport == TransportType::Polling { + let mut s = session.write().await; + s.buffer_packet(Packet::ping("")); + } + } + + for sid in to_remove { + self.store.remove(&sid); + } + } +} \ No newline at end of file diff --git a/engine/mod.rs b/engine/mod.rs new file mode 100644 index 0000000..43d26de --- /dev/null +++ b/engine/mod.rs @@ -0,0 +1,13 @@ +pub mod codec; +pub mod heartbeat; +pub mod packet; +pub mod polling; +pub mod server; +pub mod session; +pub mod upgrade; +pub mod websocket; +pub mod webtransport; + +pub use packet::{HandshakeData, Packet, PacketData, PacketType}; +pub use server::{EngineConfig, EngineServer}; +pub use session::{SessionState, SessionStore, TransportType}; diff --git a/engine/packet.rs b/engine/packet.rs new file mode 100644 index 0000000..66c2b63 --- /dev/null +++ b/engine/packet.rs @@ -0,0 +1,151 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum PacketType { + Open = 0, + Close = 1, + Ping = 2, + Pong = 3, + Message = 4, + Upgrade = 5, + Noop = 6, +} + +impl TryFrom for PacketType { + type Error = PacketError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Open), + 1 => Ok(Self::Close), + 2 => Ok(Self::Ping), + 3 => Ok(Self::Pong), + 4 => Ok(Self::Message), + 5 => Ok(Self::Upgrade), + 6 => Ok(Self::Noop), + _ => Err(PacketError::InvalidType(value)), + } + } +} + +impl TryFrom for PacketType { + type Error = PacketError; + + fn try_from(value: char) -> Result { + match value { + '0' => Ok(Self::Open), + '1' => Ok(Self::Close), + '2' => Ok(Self::Ping), + '3' => Ok(Self::Pong), + '4' => Ok(Self::Message), + '5' => Ok(Self::Upgrade), + '6' => Ok(Self::Noop), + _ => Err(PacketError::InvalidTypeChar(value)), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PacketData { + Text(String), + Binary(Vec), + Empty, +} + +#[derive(Debug, Clone)] +pub struct Packet { + pub packet_type: PacketType, + pub data: PacketData, +} + +impl Packet { + pub fn open(handshake: &HandshakeData) -> Self { + let data = serde_json::to_string(handshake) + .unwrap_or_else(|e| { + tracing::error!("Failed to serialize handshake data: {}", e); + "{}".to_string() + }); + Self { + packet_type: PacketType::Open, + data: PacketData::Text(data), + } + } + + pub fn close() -> Self { + Self { + packet_type: PacketType::Close, + data: PacketData::Empty, + } + } + + pub fn ping(data: impl Into) -> Self { + Self { + packet_type: PacketType::Ping, + data: PacketData::Text(data.into()), + } + } + + pub fn pong(data: impl Into) -> Self { + Self { + packet_type: PacketType::Pong, + data: PacketData::Text(data.into()), + } + } + + pub fn message_text(data: impl Into) -> Self { + Self { + packet_type: PacketType::Message, + data: PacketData::Text(data.into()), + } + } + + pub fn message_binary(data: Vec) -> Self { + Self { + packet_type: PacketType::Message, + data: PacketData::Binary(data), + } + } + + pub fn upgrade() -> Self { + Self { + packet_type: PacketType::Upgrade, + data: PacketData::Empty, + } + } + + pub fn noop() -> Self { + Self { + packet_type: PacketType::Noop, + data: PacketData::Empty, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandshakeData { + pub sid: String, + pub upgrades: Vec, + #[serde(rename = "pingInterval")] + pub ping_interval: u64, + #[serde(rename = "pingTimeout")] + pub ping_timeout: u64, + #[serde(rename = "maxPayload")] + pub max_payload: usize, +} + +#[derive(Debug, thiserror::Error)] +pub enum PacketError { + #[error("invalid packet type: {0}")] + InvalidType(u8), + #[error("invalid packet type char: {0}")] + InvalidTypeChar(char), + #[error("empty packet")] + Empty, + #[error("invalid base64: {0}")] + InvalidBase64(#[from] base64::DecodeError), + #[error("invalid utf8: {0}")] + InvalidUtf8(#[from] std::string::FromUtf8Error), + #[error("serialization error: {0}")] + Serialization(String), +} \ No newline at end of file diff --git a/engine/polling.rs b/engine/polling.rs new file mode 100644 index 0000000..480ae0b --- /dev/null +++ b/engine/polling.rs @@ -0,0 +1,185 @@ +use std::sync::Arc; +use std::time::Duration; + +use actix_web::{web, HttpRequest, HttpResponse}; + +use crate::engine::codec; +use crate::engine::packet::{Packet, PacketType}; +use crate::engine::server::EngineConfig; +use crate::engine::session::{SessionState, SessionStore, TransportType}; + +#[derive(Debug, serde::Deserialize)] +pub struct PollingQuery { + #[serde(rename = "EIO")] + pub eio: Option, + pub transport: Option, + pub sid: Option, +} + +pub async fn polling_get( + _req: HttpRequest, + query: web::Query, + store: web::Data, + config: web::Data, + _on_message: web::Data>, +) -> HttpResponse { + if query.eio.as_deref() != Some("4") { + return HttpResponse::BadRequest().body("invalid EIO version"); + } + + if query.transport.as_deref() != Some("polling") { + return HttpResponse::BadRequest().body("invalid transport"); + } + + let sid = match &query.sid { + Some(sid) => sid.clone(), + None => { + return handle_handshake(&store, &config).await; + } + }; + + let session = match store.get(&sid) { + Some(s) => s, + None => return HttpResponse::BadRequest().body("unknown session"), + }; + + // Check session state and take any buffered pending packets + let notify = { + let mut session_guard = session.write().await; + + if session_guard.state == SessionState::Closed { + return HttpResponse::BadRequest().body("session closed"); + } + + let pending = session_guard.take_pending(); + if !pending.is_empty() { + let payload = codec::encode_payload(&pending); + return HttpResponse::Ok() + .content_type("text/plain; charset=UTF-8") + .body(payload); + } + + session_guard.notify.clone() + }; + + let timeout = Duration::from_millis(config.ping_interval + config.ping_timeout); + let _result = tokio::time::timeout(timeout, notify.notified()).await; + + // Re-verify session still exists after wait (may have been removed by heartbeat) + if store.get(&sid).is_none() { + return HttpResponse::BadRequest().body("session closed"); + } + + let mut session_guard = session.write().await; + let packets = session_guard.take_pending(); + + if packets.is_empty() { + let noop = codec::encode_packet(&Packet::noop()); + HttpResponse::Ok() + .content_type("text/plain; charset=UTF-8") + .body(noop) + } else { + let payload = codec::encode_payload(&packets); + HttpResponse::Ok() + .content_type("text/plain; charset=UTF-8") + .body(payload) + } +} + +pub async fn polling_post( + _req: HttpRequest, + body: web::Bytes, + query: web::Query, + store: web::Data, + config: web::Data, + on_message: web::Data>, +) -> HttpResponse { + if query.eio.as_deref() != Some("4") { + return HttpResponse::BadRequest().body("invalid EIO version"); + } + + if query.transport.as_deref() != Some("polling") { + return HttpResponse::BadRequest().body("invalid transport"); + } + + // Check payload size BEFORE attempting to decode + if body.len() > config.max_payload { + return HttpResponse::PayloadTooLarge().body("payload too large"); + } + + let sid = match &query.sid { + Some(sid) => sid, + None => return HttpResponse::BadRequest().body("missing sid"), + }; + + let session = match store.get(sid) { + Some(s) => s, + None => return HttpResponse::BadRequest().body("unknown session"), + }; + + let body_str = match std::str::from_utf8(&body) { + Ok(s) => s, + Err(_) => return HttpResponse::BadRequest().body("invalid utf8"), + }; + + let packets = match codec::decode_payload(body_str) { + Ok(p) => p, + Err(_) => return HttpResponse::BadRequest().body("invalid payload"), + }; + + let mut session_guard = session.write().await; + + for packet in packets { + match packet.packet_type { + PacketType::Pong => { + session_guard.update_ping(); + } + PacketType::Message => { + let on_msg = on_message.get_ref().clone(); + let sid_owned = sid.clone(); + tokio::spawn(async move { + on_msg(sid_owned, packet); + }); + } + PacketType::Close => { + session_guard.set_state(SessionState::Closed); + store.remove(sid); + return HttpResponse::Ok().body("ok"); + } + _ => {} + } + } + + HttpResponse::Ok().body("ok") +} + +async fn handle_handshake(store: &SessionStore, config: &EngineConfig) -> HttpResponse { + let sid = crate::engine::session::generate_sid(); + + let handshake = crate::engine::packet::HandshakeData { + sid: sid.clone(), + upgrades: vec!["websocket".to_string()], + ping_interval: config.ping_interval, + ping_timeout: config.ping_timeout, + max_payload: config.max_payload, + }; + + let _rx = store.create(sid.clone(), TransportType::Polling); + + if let Some(session) = store.get(&sid) { + let mut session = session.write().await; + session.set_state(SessionState::Open); + } + + let packet = Packet::open(&handshake); + let payload = codec::encode_packet(&packet); + + HttpResponse::Ok() + .content_type("text/plain; charset=UTF-8") + .body(payload) +} + +pub fn configure_polling(cfg: &mut web::ServiceConfig) { + cfg.route("/engine.io/", web::get().to(polling_get)) + .route("/engine.io/", web::post().to(polling_post)); +} \ No newline at end of file diff --git a/engine/server.rs b/engine/server.rs new file mode 100644 index 0000000..018f5fb --- /dev/null +++ b/engine/server.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use actix_web::{web, App, HttpServer}; + +use crate::engine::heartbeat::HeartbeatManager; +use crate::engine::packet::Packet; +use crate::engine::session::SessionStore; + +#[derive(Debug, Clone)] +pub struct EngineConfig { + pub ping_interval: u64, + pub ping_timeout: u64, + pub max_payload: usize, + pub path: String, +} + +impl Default for EngineConfig { + fn default() -> Self { + Self { + ping_interval: 25000, + ping_timeout: 20000, + max_payload: 1_000_000, + path: "/engine.io/".to_string(), + } + } +} + +pub struct EngineServer { + pub config: EngineConfig, + pub store: SessionStore, + on_message: Arc, +} + +impl EngineServer { + pub fn new( + config: EngineConfig, + on_message: impl Fn(String, Packet) + Send + Sync + 'static, + ) -> Self { + Self { + config, + store: SessionStore::new(), + on_message: Arc::new(on_message), + } + } + + pub fn with_store( + config: EngineConfig, + store: SessionStore, + on_message: impl Fn(String, Packet) + Send + Sync + 'static, + ) -> Self { + Self { + config, + store, + on_message: Arc::new(on_message), + } + } + + pub async fn run_http(self: Arc, addr: &str) -> std::io::Result<()> { + let store = self.store.clone(); + let config = self.config.clone(); + let on_message = self.on_message.clone(); + + // Start heartbeat manager to clean up stale sessions + let heartbeat = Arc::new(HeartbeatManager::new( + store.clone(), + config.ping_interval, + config.ping_timeout, + )); + let heartbeat_handle = heartbeat.start(); + + tracing::info!("Engine.IO HTTP server listening on {}", addr); + + let result = HttpServer::new(move || { + App::new() + .app_data(web::Data::new(store.clone())) + .app_data(web::Data::new(config.clone())) + .app_data(web::Data::new(on_message.clone())) + .route( + "/engine.io/", + web::get().to(crate::engine::polling::polling_get), + ) + .route( + "/engine.io/", + web::post().to(crate::engine::polling::polling_post), + ) + .route( + "/engine.io/", + web::get().to(crate::engine::websocket::websocket_handler), + ) + }) + .bind(addr)? + .run() + .await; + + heartbeat_handle.abort(); + result + } + + pub async fn run_webtransport( + &self, + port: u16, + cert_path: &str, + key_path: &str, + ) -> Result<(), Box> { + crate::engine::webtransport::run_webtransport_server( + port, + cert_path, + key_path, + self.store.clone(), + self.config.clone(), + self.on_message.clone(), + ) + .await + } +} diff --git a/engine/session.rs b/engine/session.rs new file mode 100644 index 0000000..30b6bb0 --- /dev/null +++ b/engine/session.rs @@ -0,0 +1,171 @@ +use std::sync::Arc; +use std::time::Instant; + +use dashmap::DashMap; +use tokio::sync::{mpsc, Notify}; + +use crate::engine::packet::Packet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportType { + Polling, + WebSocket, + WebTransport, +} + +impl TransportType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Polling => "polling", + Self::WebSocket => "websocket", + Self::WebTransport => "webtransport", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + Connecting, + Open, + Upgrading, + Closing, + Closed, +} + +pub struct Session { + pub sid: String, + pub transport: TransportType, + pub state: SessionState, + pub created_at: Instant, + pub last_ping: Instant, + pub tx: mpsc::Sender, + pub pending_packets: Vec, + pub notify: Arc, + pub upgrade_tx: Option>, +} + +impl Session { + pub fn new(sid: String, transport: TransportType) -> (Self, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(256); + let session = Self { + sid, + transport, + state: SessionState::Connecting, + created_at: Instant::now(), + last_ping: Instant::now(), + tx, + pending_packets: Vec::new(), + notify: Arc::new(Notify::new()), + upgrade_tx: None, + }; + (session, rx) + } + + /// Send a packet through the mpsc channel (for WS/WT transport consumption). + pub fn send_packet(&self, packet: Packet) -> Result<(), mpsc::error::TrySendError> { + self.tx.try_send(packet) + } + + /// Push a packet using the appropriate mechanism for the current transport. + /// Polling: buffer in pending_packets + notify waiting GET request. + /// WS/WT: try mpsc channel first; if full, buffer as fallback + notify. + pub fn push_packet(&mut self, packet: Packet) { + if self.transport == TransportType::Polling { + self.pending_packets.push(packet); + self.notify.notify_one(); + } else { + if self.tx.try_send(packet.clone()).is_err() { + self.pending_packets.push(packet); + self.notify.notify_one(); + } + } + } + + /// Buffer a packet in pending_packets and notify any waiting polling request. + pub fn buffer_packet(&mut self, packet: Packet) { + self.pending_packets.push(packet); + self.notify.notify_one(); + } + + pub fn take_pending(&mut self) -> Vec { + std::mem::take(&mut self.pending_packets) + } + + pub fn update_ping(&mut self) { + self.last_ping = Instant::now(); + } + + pub fn set_transport(&mut self, transport: TransportType) { + self.transport = transport; + } + + pub fn set_state(&mut self, state: SessionState) { + self.state = state; + } +} + +#[derive(Clone)] +pub struct SessionStore { + pub sessions: Arc>>>, +} + +impl SessionStore { + pub fn new() -> Self { + Self { + sessions: Arc::new(DashMap::new()), + } + } + + /// Create a new session. Returns the mpsc receiver for transport-level packet consumption. + /// Logs a warning if the SID collides with an existing session (extremely unlikely with crypto RNG). + pub fn create(&self, sid: String, transport: TransportType) -> mpsc::Receiver { + let (session, rx) = Session::new(sid.clone(), transport); + let old = self + .sessions + .insert(sid.clone(), Arc::new(tokio::sync::RwLock::new(session))); + if old.is_some() { + tracing::warn!("Session ID collision for SID {}, replacing existing session", sid); + } + rx + } + + pub fn get(&self, sid: &str) -> Option>> { + self.sessions.get(sid).map(|r| r.value().clone()) + } + + pub fn remove(&self, sid: &str) { + self.sessions.remove(sid); + } + + pub fn exists(&self, sid: &str) -> bool { + self.sessions.contains_key(sid) + } + + pub fn len(&self) -> usize { + self.sessions.len() + } + + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } +} + +impl Default for SessionStore { + fn default() -> Self { + Self::new() + } +} + +/// Generate a random session ID using a cryptographically secure RNG. +/// rand 0.9's default RNG (ChaCha8Rng seeded from OsRng) is crypto-secure. +pub fn generate_sid() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; + let mut rng = rand::rng(); + (0..20) + .map(|_| { + let idx = rng.random_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} \ No newline at end of file diff --git a/engine/upgrade.rs b/engine/upgrade.rs new file mode 100644 index 0000000..695a7d8 --- /dev/null +++ b/engine/upgrade.rs @@ -0,0 +1,53 @@ +use crate::engine::packet::Packet; +use crate::engine::session::{SessionState, SessionStore, TransportType}; + +pub async fn handle_upgrade_probe( + store: &SessionStore, + sid: &str, +) -> Result { + let session = store.get(sid).ok_or(UpgradeError::SessionNotFound)?; + let mut session = session.write().await; + + if session.state == SessionState::Closed { + return Err(UpgradeError::SessionClosed); + } + + session.set_state(SessionState::Upgrading); + Ok(Packet::pong("probe")) +} + +pub async fn handle_upgrade_complete( + store: &SessionStore, + sid: &str, + new_transport: TransportType, +) -> Result<(), UpgradeError> { + let session = store.get(sid).ok_or(UpgradeError::SessionNotFound)?; + let mut session = session.write().await; + + session.set_transport(new_transport); + session.set_state(SessionState::Open); + + Ok(()) +} + +pub async fn send_noop_to_pending_polling( + store: &SessionStore, + sid: &str, +) -> Result<(), UpgradeError> { + let session = store.get(sid).ok_or(UpgradeError::SessionNotFound)?; + let mut session = session.write().await; + + session.buffer_packet(Packet::noop()); + + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum UpgradeError { + #[error("session not found")] + SessionNotFound, + #[error("session closed")] + SessionClosed, + #[error("invalid state for upgrade")] + InvalidState, +} diff --git a/engine/websocket.rs b/engine/websocket.rs new file mode 100644 index 0000000..2993c7d --- /dev/null +++ b/engine/websocket.rs @@ -0,0 +1,254 @@ +use std::sync::Arc; + +use actix_web::{web, HttpRequest, HttpResponse}; +use actix_ws::Message; + +use crate::engine::codec; +use crate::engine::packet::{Packet, PacketData, PacketType}; +use crate::engine::server::EngineConfig; +use crate::engine::session::{SessionState, SessionStore, TransportType}; + +#[derive(Debug, serde::Deserialize)] +pub struct WsQuery { + #[serde(rename = "EIO")] + pub eio: Option, + pub transport: Option, + pub sid: Option, +} + +pub async fn websocket_handler( + req: HttpRequest, + body: web::Payload, + query: web::Query, + store: web::Data, + config: web::Data, + on_message: web::Data>, +) -> Result { + if query.eio.as_deref() != Some("4") { + return Ok(HttpResponse::BadRequest().body("invalid EIO version")); + } + + if query.transport.as_deref() != Some("websocket") { + return Ok(HttpResponse::BadRequest().body("invalid transport")); + } + + let (response, mut ws_session, mut msg_stream) = actix_ws::handle(&req, body)?; + + let sid = query.sid.clone(); + + let is_upgrade = sid.as_ref().map(|s| store.exists(s)).unwrap_or(false); + + // Create or reuse session, obtaining the mpsc receiver for the forwarding task + let (session_sid, mut session_rx) = if let Some(ref sid) = sid { + if is_upgrade { + // Upgrade: session already exists, replace its channel and drain pending packets + let session_arc = store.get(sid).unwrap(); + let (new_tx, new_rx) = tokio::sync::mpsc::channel(256); + { + let mut s = session_arc.write().await; + // Swap tx atomically: old_tx will be dropped, closing its channel. + // Any packets in the old rx are consumed by the old send_handle, + // which then exits when it sees the channel close. + // Drain pending_packets (from polling buffering) into new channel. + let pending = s.take_pending(); + for packet in pending { + let _ = new_tx.try_send(packet); + } + s.tx = new_tx; + s.set_transport(TransportType::WebSocket); + } + (sid.clone(), new_rx) + } else { + // Reconnect with known SID: create new session + let rx = store.create(sid.clone(), TransportType::WebSocket); + if let Some(s) = store.get(sid) { + let mut s = s.write().await; + s.set_state(SessionState::Open); + } + (sid.clone(), rx) + } + } else { + // New connection: generate SID and create session + let new_sid = crate::engine::session::generate_sid(); + let rx = store.create(new_sid.clone(), TransportType::WebSocket); + if let Some(s) = store.get(&new_sid) { + let mut s = s.write().await; + s.set_state(SessionState::Open); + } + (new_sid, rx) + }; + + let handshake = crate::engine::packet::HandshakeData { + sid: session_sid.clone(), + upgrades: vec![], + ping_interval: config.ping_interval, + ping_timeout: config.ping_timeout, + max_payload: config.max_payload, + }; + + let open_packet = Packet::open(&handshake); + let open_msg = codec::encode_packet(&open_packet); + if ws_session.text(open_msg).await.is_err() { + tracing::warn!("Failed to send open packet to WebSocket session {}", session_sid); + store.remove(&session_sid); + return Ok(response); + } + + let store_clone = store.get_ref().clone(); + let on_message_clone = on_message.get_ref().clone(); + let sid_clone = session_sid.clone(); + let ws_session_clone = ws_session.clone(); + let max_payload = config.max_payload; + + // Task 1: Forward engine session packets → WebSocket (reads from session mpsc rx) + let sid_for_send = session_sid.clone(); + let store_for_send = store.get_ref().clone(); + let mut ws_for_send = ws_session.clone(); + let send_handle = actix_rt::spawn(async move { + while let Some(packet) = session_rx.recv().await { + let encoded = codec::encode_packet(&packet); + if ws_for_send.text(encoded).await.is_err() { + break; + } + } + // Session channel closed — clean up + store_for_send.remove(&sid_for_send); + }); + + // Task 2: Read incoming WebSocket messages → dispatch + let recv_handle = actix_rt::spawn(async move { + let mut ws_session = ws_session_clone; + while let Some(Ok(msg)) = msg_stream.recv().await { + match msg { + Message::Text(text) => { + if let Ok(packet) = codec::decode_packet(&text) { + match packet.packet_type { + PacketType::Ping => { + if let PacketData::Text(ref data) = packet.data { + if data == "probe" { + let pong = Packet::pong("probe"); + let pong_msg = codec::encode_packet(&pong); + let _ = ws_session.text(pong_msg).await; + continue; + } + } + let pong = Packet::pong(""); + let pong_msg = codec::encode_packet(&pong); + let _ = ws_session.text(pong_msg).await; + } + PacketType::Pong => { + if let Some(s) = store_clone.get(&sid_clone) { + let mut s = s.write().await; + s.update_ping(); + } + } + PacketType::Upgrade => { + if let Some(s) = store_clone.get(&sid_clone) { + let mut s = s.write().await; + s.set_transport(TransportType::WebSocket); + s.set_state(SessionState::Open); + } + } + PacketType::Message => { + let on_msg = on_message_clone.clone(); + let sid = sid_clone.clone(); + tokio::spawn(async move { + on_msg(sid, packet); + }); + } + PacketType::Close => { + if let Some(s) = store_clone.get(&sid_clone) { + let mut s = s.write().await; + s.set_state(SessionState::Closed); + } + store_clone.remove(&sid_clone); + let _ = ws_session.close(None).await; + break; + } + _ => {} + } + } + } + Message::Binary(bin) => { + // Enforce max payload size for binary frames + if bin.len() > max_payload { + tracing::warn!( + "Binary payload too large ({}) for session {}", + bin.len(), + sid_clone + ); + continue; + } + + if let Ok(packet) = codec::decode_packet_ws(&bin) { + if packet.packet_type == PacketType::Message { + let on_msg = on_message_clone.clone(); + let sid = sid_clone.clone(); + tokio::spawn(async move { + on_msg(sid, packet); + }); + } + } + } + Message::Close(_) => { + if let Some(s) = store_clone.get(&sid_clone) { + let mut s = s.write().await; + s.set_state(SessionState::Closed); + } + store_clone.remove(&sid_clone); + break; + } + _ => {} + } + } + }); + + // Task 3: Heartbeat ping sender + let sid_for_ping = session_sid.clone(); + let store_for_ping = store.get_ref().clone(); + let mut ws_for_ping = ws_session.clone(); + let ping_interval = config.ping_interval; + let ping_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_millis(ping_interval)); + + loop { + interval.tick().await; + + if let Some(s) = store_for_ping.get(&sid_for_ping) { + let session_state = { + let s = s.read().await; + s.state + }; + + if session_state == SessionState::Closed { + break; + } + + let ping = Packet::ping(""); + let ping_msg = codec::encode_packet(&ping); + if ws_for_ping.text(ping_msg).await.is_err() { + break; + } + } else { + break; + } + } + }); + + // Wait for any task to finish, then clean up + actix_rt::spawn(async move { + // actix_rt::spawn returns JoinHandle which is compatible with tokio::select! + tokio::select! { + _ = send_handle => {}, + _ = recv_handle => {}, + _ = ping_handle => {}, + } + store.remove(&session_sid); + }); + + Ok(response) +} + +pub fn configure_websocket(cfg: &mut web::ServiceConfig) { + cfg.route("/engine.io/", web::get().to(websocket_handler)); +} diff --git a/engine/webtransport.rs b/engine/webtransport.rs new file mode 100644 index 0000000..5e25970 --- /dev/null +++ b/engine/webtransport.rs @@ -0,0 +1,223 @@ +use std::sync::Arc; + +use wtransport::{Connection, Endpoint, ServerConfig, Identity}; + +use crate::engine::codec; +use crate::engine::packet::{Packet, PacketType}; +use crate::engine::server::EngineConfig; +use crate::engine::session::{SessionState, SessionStore, TransportType}; + +pub async fn run_webtransport_server( + port: u16, + cert_path: &str, + key_path: &str, + store: SessionStore, + config: EngineConfig, + on_message: Arc, +) -> Result<(), Box> { + let identity = Identity::load_pemfiles(cert_path, key_path).await?; + + let server_config = ServerConfig::builder() + .with_bind_default(port) + .with_identity(identity) + .build(); + + let server = Endpoint::server(server_config)?; + + tracing::info!("WebTransport server listening on UDP port {}", port); + + loop { + let incoming = server.accept().await; + + let store = store.clone(); + let config = config.clone(); + let on_message = on_message.clone(); + + tokio::spawn(async move { + match handle_webtransport_session(incoming, store, config, on_message).await { + Ok(_) => {} + Err(e) => { + tracing::error!("WebTransport session error: {}", e); + } + } + }); + } +} + +async fn handle_webtransport_session( + incoming: wtransport::endpoint::IncomingSession, + store: SessionStore, + config: EngineConfig, + on_message: Arc, +) -> Result<(), Box> { + let request = incoming.await?; + let connection = request.accept().await?; + + let sid = crate::engine::session::generate_sid(); + let mut rx = store.create(sid.clone(), TransportType::WebTransport); + + if let Some(s) = store.get(&sid) { + let mut s = s.write().await; + s.set_state(SessionState::Open); + } + + let handshake = crate::engine::packet::HandshakeData { + sid: sid.clone(), + upgrades: vec![], + ping_interval: config.ping_interval, + ping_timeout: config.ping_timeout, + max_payload: config.max_payload, + }; + + let open_packet = Packet::open(&handshake); + send_wt_packet(&connection, &open_packet).await?; + + let store_clone = store.clone(); + let sid_clone = sid.clone(); + let on_message_clone = on_message.clone(); + let connection_recv = connection.clone(); + let max_payload = config.max_payload; + + // Reuse buffer across recv iterations instead of allocating 65KB each time + let recv_handle = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + loop { + match connection_recv.accept_bi().await { + Ok((mut send, mut recv)) => { + // Reset buffer length for the next read without deallocating + buf.resize(65536, 0); + match recv.read(&mut buf).await { + Ok(Some(n)) => { + if n > max_payload { + tracing::warn!( + "WebTransport payload too large ({}) for session {}", + n, + sid_clone + ); + continue; + } + if let Ok(packet) = codec::decode_packet_ws(&buf[..n]) { + match packet.packet_type { + PacketType::Ping => { + let pong = Packet::pong(""); + if send_wt_packet_on_stream(&mut send, &pong) + .await + .is_err() + { + break; + } + } + PacketType::Pong => { + if let Some(s) = store_clone.get(&sid_clone) { + let mut s = s.write().await; + s.update_ping(); + } + } + PacketType::Message => { + let on_msg = on_message_clone.clone(); + let sid = sid_clone.clone(); + tokio::spawn(async move { + on_msg(sid, packet); + }); + } + PacketType::Close => { + if let Some(s) = store_clone.get(&sid_clone) { + let mut s = s.write().await; + s.set_state(SessionState::Closed); + } + store_clone.remove(&sid_clone); + break; + } + _ => {} + } + } + } + Ok(None) => break, + Err(_) => break, + } + } + Err(_) => break, + } + } + Ok::<(), Box>(()) + }); + + let connection_send = connection.clone(); + let send_handle = tokio::spawn(async move { + while let Some(packet) = rx.recv().await { + if send_wt_packet(&connection_send, &packet).await.is_err() { + break; + } + } + }); + + let connection_ping = connection.clone(); + let store_ping = store.clone(); + let sid_ping = sid.clone(); + let ping_interval = config.ping_interval; + let ping_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_millis(ping_interval)); + + loop { + interval.tick().await; + + if let Some(s) = store_ping.get(&sid_ping) { + let state = { + let s = s.read().await; + s.state + }; + + if state == SessionState::Closed { + break; + } + + let ping = Packet::ping(""); + if send_wt_packet(&connection_ping, &ping).await.is_err() { + break; + } + } else { + break; + } + } + }); + + tokio::select! { + _ = recv_handle => {}, + _ = send_handle => {}, + _ = ping_handle => {}, + } + + store.remove(&sid); + Ok(()) +} + +async fn send_wt_packet( + connection: &Connection, + packet: &Packet, +) -> Result<(), Box> { + let (mut send, _recv) = connection.open_bi().await?.await?; + let encoded = codec::encode_packet_binary_ws(packet); + let is_binary = matches!(packet.data, crate::engine::packet::PacketData::Binary(_)); + let header = codec::encode_webtransport_header(encoded.len(), is_binary); + + send.write_all(&header).await?; + send.write_all(&encoded).await?; + send.finish().await?; + + Ok(()) +} + +async fn send_wt_packet_on_stream( + send: &mut wtransport::SendStream, + packet: &Packet, +) -> Result<(), Box> { + let encoded = codec::encode_packet_binary_ws(packet); + let is_binary = matches!(packet.data, crate::engine::packet::PacketData::Binary(_)); + let header = codec::encode_webtransport_header(encoded.len(), is_binary); + + send.write_all(&header).await?; + send.write_all(&encoded).await?; + send.finish().await?; + + Ok(()) +} \ No newline at end of file diff --git a/lib.rs b/lib.rs new file mode 100644 index 0000000..91d7541 --- /dev/null +++ b/lib.rs @@ -0,0 +1,3 @@ +pub mod pb; +pub mod socket; +pub mod engine; \ No newline at end of file diff --git a/main.rs b/main.rs new file mode 100644 index 0000000..89aea43 --- /dev/null +++ b/main.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use imks::engine::server::EngineConfig; +use imks::socket::server::SocketServer; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let config = EngineConfig::default(); + let socket_server = Arc::new(SocketServer::new(config)); + + let addr = "0.0.0.0:3000"; + tracing::info!("Starting Socket.IO server on {}", addr); + + tokio::runtime::Runtime::new() + .expect("Failed to create Tokio runtime") + .block_on(async { + let namespace = socket_server.of("/"); + namespace + .on_connect(|socket, _auth| { + tracing::info!( + "Socket {} connected (engine: {})", + socket.sid, + socket.engine_sid + ); + Ok(()) + }) + .await; + + socket_server.run_http(addr).await.expect("Server error"); + }); +} \ No newline at end of file diff --git a/pb/core.rs b/pb/core.rs new file mode 100644 index 0000000..5345620 --- /dev/null +++ b/pb/core.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/appks.core.v1.rs")); \ No newline at end of file diff --git a/pb/im.rs b/pb/im.rs new file mode 100644 index 0000000..39b0044 --- /dev/null +++ b/pb/im.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/appks.im.v1.rs")); diff --git a/pb/mod.rs b/pb/mod.rs new file mode 100644 index 0000000..88e1db6 --- /dev/null +++ b/pb/mod.rs @@ -0,0 +1,2 @@ +pub mod core; +pub mod im; \ No newline at end of file diff --git a/proto/core/auth.proto b/proto/core/auth.proto new file mode 100644 index 0000000..1ecc5a9 --- /dev/null +++ b/proto/core/auth.proto @@ -0,0 +1,124 @@ +syntax = "proto3"; + +package appks.core.v1; + +// ============================================================ +// JWT Payload +// ============================================================ + +message TokenClaims { + string sub = 1; // user id (uuid) + string iss = 2; // issuer (e.g. "appks") + int64 iat = 3; // issued at (unix seconds) + int64 exp = 4; // expires at (unix seconds) + string jti = 5; // unique token id (for revocation) + string scope = 6; // space-separated scopes + map extra = 7; // extensible fields (workspace_id, role, etc.) +} + +// ============================================================ +// Issue (appks REST API → core) +// ============================================================ + +message IssueTokenRequest { + string user_id = 1; + int64 ttl_secs = 2; // access token lifetime + repeated string scopes = 3; + map extra = 4; +} + +message IssueTokenResponse { + string access_token = 1; // JWT + string refresh_token = 2; // opaque, stored in Redis + int64 expires_at = 3; + string key_id = 4; // kid header for the signing key +} + +// ============================================================ +// Refresh +// ============================================================ + +message RefreshTokenRequest { + string refresh_token = 1; +} + +message RefreshTokenResponse { + string access_token = 1; + string refresh_token = 2; // rotated + int64 expires_at = 3; + string key_id = 4; +} + +// ============================================================ +// Revoke +// ============================================================ + +message RevokeTokenRequest { + oneof target { + string jti = 1; // revoke single token + string user_id = 2; // revoke all tokens for user + } +} + +message RevokeTokenResponse { + int32 revoked_count = 1; +} + +// ============================================================ +// Verify (imks → core, RPC 模式) +// imks 把客户端携带的 JWT 发给 core 验证 +// ============================================================ + +message VerifyTokenRequest { + string token = 1; +} + +message VerifyTokenResponse { + bool valid = 1; + TokenClaims claims = 2; // only set when valid = true + string reason = 3; // "expired", "revoked", "invalid_signature", etc. +} + +// ============================================================ +// Key Distribution (imks → core, 本地验证模式) +// imks 拉取公钥/解密密钥,本地验证 JWT,无需每次 RPC +// 密钥窗口 3h,imks 定期刷新 +// ============================================================ + +message SigningKey { + string kid = 1; // key id (matches JWT header kid) + string algorithm = 2; // "HS256", "RS256", "EdDSA", ... + string key_material = 3; // 对称: base64 secret / 非对称: PEM public key + int64 issued_at = 4; // 签发时间 + int64 expires_at = 5; // 过期时间 (issued_at + 3h window) + bool active = 6; // 是否为当前活跃签名密钥 +} + +message GetSigningKeysRequest { + // 空 = 返回所有未过期密钥 + // 非空 = 只返回指定 kid 的密钥 + string kid = 1; +} + +message GetSigningKeysResponse { + repeated SigningKey keys = 1; // 可能同时有多个有效密钥(滚动窗口) + int64 next_rotation_at = 2; // 下次密钥轮换时间,imks 据此安排刷新 +} + +// ============================================================ +// Service +// ============================================================ + +service TokenService { + // --- 令牌生命周期 (appks REST handler 调用) --- + rpc IssueToken(IssueTokenRequest) returns (IssueTokenResponse); + rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse); + rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse); + + // --- imks 验证 (RPC 模式) --- + rpc VerifyToken(VerifyTokenRequest) returns (VerifyTokenResponse); + + // --- imks 密钥拉取 (本地验证模式) --- + // imks 启动时拉取,之后根据 next_rotation_at 定期刷新 + rpc GetSigningKeys(GetSigningKeysRequest) returns (GetSigningKeysResponse); +} diff --git a/proto/core/channel.proto b/proto/core/channel.proto new file mode 100644 index 0000000..4b0fec2 --- /dev/null +++ b/proto/core/channel.proto @@ -0,0 +1,208 @@ +syntax = "proto3"; + +package appks.im.v1; + +import "google/protobuf/timestamp.proto"; + +// Channel management service for the IM microservice. +// Provides CRUD for channels and categories, plus channel statistics. + + +enum ChannelType { + CHANNEL_TYPE_UNSPECIFIED = 0; + CHANNEL_TYPE_PUBLIC = 1; + CHANNEL_TYPE_PRIVATE = 2; + CHANNEL_TYPE_DIRECT = 3; + CHANNEL_TYPE_GROUP = 4; + CHANNEL_TYPE_REPO = 5; + CHANNEL_TYPE_SYSTEM = 6; +} + +enum ChannelKind { + CHANNEL_KIND_UNSPECIFIED = 0; + CHANNEL_KIND_TEXT = 1; + CHANNEL_KIND_VOICE = 2; + CHANNEL_KIND_STAGE = 3; + CHANNEL_KIND_FORUM = 4; + CHANNEL_KIND_ANNOUNCEMENT = 5; +} + +enum Visibility { + VISIBILITY_UNSPECIFIED = 0; + VISIBILITY_PUBLIC = 1; + VISIBILITY_PRIVATE = 2; + VISIBILITY_INTERNAL = 3; + VISIBILITY_WORKSPACE = 4; + VISIBILITY_PROTECTED = 5; + VISIBILITY_HIDDEN = 6; + VISIBILITY_SECRET = 7; +} + + +message Channel { + string id = 1; + string workspace_id = 2; + optional string category_id = 3; + optional string parent_channel_id = 4; + string name = 5; + optional string topic = 6; + optional string description = 7; + ChannelType channel_type = 8; + ChannelKind channel_kind = 9; + Visibility visibility = 10; + int32 position = 11; + bool nsfw = 12; + bool read_only = 13; + bool archived = 14; + optional string created_by = 15; + optional int32 rate_limit_per_user = 16; + optional google.protobuf.Timestamp archived_at = 17; + optional string last_message_id = 18; + optional google.protobuf.Timestamp last_message_at = 19; + google.protobuf.Timestamp created_at = 20; + google.protobuf.Timestamp updated_at = 21; +} + +message ChannelStats { + string channel_id = 1; + int32 members_count = 2; + int32 messages_count = 3; + int32 threads_count = 4; + int32 reactions_count = 5; + int32 mentions_count = 6; + int32 files_count = 7; + optional google.protobuf.Timestamp last_activity_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +message ChannelCategory { + string id = 1; + string workspace_id = 2; + string name = 3; + int32 position = 4; + bool collapsed = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + + +message GetChannelRequest { + string channel_id = 1; +} + +message GetChannelResponse { + Channel channel = 1; +} + +message ListChannelsRequest { + string workspace_name = 1; + optional string category_id = 2; + optional ChannelType channel_type = 3; + optional ChannelKind channel_kind = 4; + int32 limit = 5; + int32 offset = 6; +} + +message ListChannelsResponse { + repeated Channel channels = 1; + int32 total = 2; +} + +message CreateChannelRequest { + string workspace_name = 1; + string name = 2; + optional string topic = 3; + optional string description = 4; + optional string channel_type = 5; + optional string channel_kind = 6; + optional string visibility = 7; + optional string category_id = 8; + optional string parent_channel_id = 9; + optional string created_by = 10; + optional int32 rate_limit_per_user = 11; +} + +message CreateChannelResponse { + Channel channel = 1; +} + +message UpdateChannelRequest { + string channel_id = 1; + optional string name = 2; + optional string topic = 3; + optional string description = 4; + optional string visibility = 5; + optional int32 position = 6; + optional bool nsfw = 7; + optional bool read_only = 8; + optional bool archived = 9; + optional string category_id = 10; + optional int32 rate_limit_per_user = 11; +} + +message UpdateChannelResponse { + Channel channel = 1; +} + +message DeleteChannelRequest { + string channel_id = 1; +} + +message DeleteChannelResponse {} + +message GetChannelStatsRequest { + string channel_id = 1; +} + +message GetChannelStatsResponse { + ChannelStats stats = 1; +} + +message ListCategoriesRequest { + string workspace_name = 1; +} + +message ListCategoriesResponse { + repeated ChannelCategory categories = 1; +} + +message CreateCategoryRequest { + string workspace_name = 1; + string name = 2; + optional int32 position = 3; +} + +message CreateCategoryResponse { + ChannelCategory category = 1; +} + +message UpdateCategoryRequest { + string category_id = 1; + optional string name = 2; + optional int32 position = 3; + optional bool collapsed = 4; +} + +message UpdateCategoryResponse { + ChannelCategory category = 1; +} + +message DeleteCategoryRequest { + string category_id = 1; +} + +message DeleteCategoryResponse {} + + +service ChannelService { + rpc GetChannel(GetChannelRequest) returns (GetChannelResponse); + rpc ListChannels(ListChannelsRequest) returns (ListChannelsResponse); + rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse); + rpc UpdateChannel(UpdateChannelRequest) returns (UpdateChannelResponse); + rpc DeleteChannel(DeleteChannelRequest) returns (DeleteChannelResponse); + rpc GetChannelStats(GetChannelStatsRequest) returns (GetChannelStatsResponse); + rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse); + rpc CreateCategory(CreateCategoryRequest) returns (CreateCategoryResponse); + rpc UpdateCategory(UpdateCategoryRequest) returns (UpdateCategoryResponse); + rpc DeleteCategory(DeleteCategoryRequest) returns (DeleteCategoryResponse); +} diff --git a/proto/core/channel_settings.proto b/proto/core/channel_settings.proto new file mode 100644 index 0000000..0eea8a1 --- /dev/null +++ b/proto/core/channel_settings.proto @@ -0,0 +1,401 @@ +syntax = "proto3"; + +package appks.im.v1; + +import "google/protobuf/timestamp.proto"; + + +message ChannelRole { + string id = 1; + string channel_id = 2; + string name = 3; + repeated string permissions = 4; + bool assignable = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +message ListChannelRolesRequest { string channel_id = 1; } +message ListChannelRolesResponse { repeated ChannelRole roles = 1; } + +message CreateChannelRoleRequest { + string channel_id = 1; + string name = 2; + repeated string permissions = 3; + bool assignable = 4; +} +message CreateChannelRoleResponse { ChannelRole role = 1; } + +message UpdateChannelRoleRequest { + string role_id = 1; + optional string name = 2; + repeated string permissions = 3; + optional bool assignable = 4; +} +message UpdateChannelRoleResponse { ChannelRole role = 1; } + +message DeleteChannelRoleRequest { string role_id = 1; } +message DeleteChannelRoleResponse {} + +service ChannelRoleService { + rpc ListChannelRoles(ListChannelRolesRequest) returns (ListChannelRolesResponse); + rpc CreateChannelRole(CreateChannelRoleRequest) returns (CreateChannelRoleResponse); + rpc UpdateChannelRole(UpdateChannelRoleRequest) returns (UpdateChannelRoleResponse); + rpc DeleteChannelRole(DeleteChannelRoleRequest) returns (DeleteChannelRoleResponse); +} + + +message ChannelInvitation { + string id = 1; + string channel_id = 2; + string invited_by = 3; + string invited_user_id = 4; + string role = 5; + string status = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} + +message ListInvitationsRequest { string channel_id = 1; } +message ListInvitationsResponse { repeated ChannelInvitation invitations = 1; } + +message CreateInvitationRequest { + string channel_id = 1; + string invited_user_id = 2; + string role = 3; +} +message CreateInvitationResponse { ChannelInvitation invitation = 1; } + +message AcceptInvitationRequest { string invitation_id = 1; } +message AcceptInvitationResponse { ChannelInvitation invitation = 1; } + +message RevokeInvitationRequest { string invitation_id = 1; } +message RevokeInvitationResponse {} + +service ChannelInvitationService { + rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse); + rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse); + rpc AcceptInvitation(AcceptInvitationRequest) returns (AcceptInvitationResponse); + rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse); +} + + +message ChannelWebhook { + string id = 1; + string channel_id = 2; + string name = 3; + string url = 4; + string secret = 5; + repeated string events = 6; + bool active = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +message ListWebhooksRequest { string channel_id = 1; } +message ListWebhooksResponse { repeated ChannelWebhook webhooks = 1; } + +message CreateWebhookRequest { + string channel_id = 1; + string name = 2; + string url = 3; + optional string secret = 4; + repeated string events = 5; +} +message CreateWebhookResponse { ChannelWebhook webhook = 1; } + +message UpdateWebhookRequest { + string webhook_id = 1; + optional string name = 2; + optional string url = 3; + optional string secret = 4; + repeated string events = 5; + optional bool active = 6; +} +message UpdateWebhookResponse { ChannelWebhook webhook = 1; } + +message DeleteWebhookRequest { string webhook_id = 1; } +message DeleteWebhookResponse {} + +service ChannelWebhookService { + rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse); + rpc CreateWebhook(CreateWebhookRequest) returns (CreateWebhookResponse); + rpc UpdateWebhook(UpdateWebhookRequest) returns (UpdateWebhookResponse); + rpc DeleteWebhook(DeleteWebhookRequest) returns (DeleteWebhookResponse); +} + + +message ChannelSlashCommand { + string id = 1; + string channel_id = 2; + string command = 3; + string description = 4; + string request_url = 5; + repeated string scopes = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} + +message ListSlashCommandsRequest { string channel_id = 1; } +message ListSlashCommandsResponse { repeated ChannelSlashCommand commands = 1; } + +message CreateSlashCommandRequest { + string channel_id = 1; + string command = 2; + string description = 3; + string request_url = 4; + repeated string scopes = 5; +} +message CreateSlashCommandResponse { ChannelSlashCommand command = 1; } + +message UpdateSlashCommandRequest { + string command_id = 1; + optional string description = 2; + optional string request_url = 3; + repeated string scopes = 4; +} +message UpdateSlashCommandResponse { ChannelSlashCommand command = 1; } + +message DeleteSlashCommandRequest { string command_id = 1; } +message DeleteSlashCommandResponse {} + +service ChannelSlashCommandService { + rpc ListSlashCommands(ListSlashCommandsRequest) returns (ListSlashCommandsResponse); + rpc CreateSlashCommand(CreateSlashCommandRequest) returns (CreateSlashCommandResponse); + rpc UpdateSlashCommand(UpdateSlashCommandRequest) returns (UpdateSlashCommandResponse); + rpc DeleteSlashCommand(DeleteSlashCommandRequest) returns (DeleteSlashCommandResponse); +} + + +message ChannelRepoLink { + string id = 1; + string channel_id = 2; + string repo_id = 3; + string link_type = 4; + repeated string events = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +message ListRepoLinksRequest { string channel_id = 1; } +message ListRepoLinksResponse { repeated ChannelRepoLink links = 1; } + +message CreateRepoLinkRequest { + string channel_id = 1; + string repo_id = 2; + string link_type = 3; + repeated string events = 4; +} +message CreateRepoLinkResponse { ChannelRepoLink link = 1; } + +message DeleteRepoLinkRequest { string link_id = 1; } +message DeleteRepoLinkResponse {} + +service ChannelRepoLinkService { + rpc ListRepoLinks(ListRepoLinksRequest) returns (ListRepoLinksResponse); + rpc CreateRepoLink(CreateRepoLinkRequest) returns (CreateRepoLinkResponse); + rpc DeleteRepoLink(DeleteRepoLinkRequest) returns (DeleteRepoLinkResponse); +} + + +message ImIntegration { + string id = 1; + string channel_id = 2; + string provider = 3; + string external_channel_id = 4; + string sync_direction = 5; + bool active = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} + +message ListIntegrationsRequest { string channel_id = 1; } +message ListIntegrationsResponse { repeated ImIntegration integrations = 1; } + +message CreateIntegrationRequest { + string channel_id = 1; + string provider = 2; + string external_channel_id = 3; + string sync_direction = 4; +} +message CreateIntegrationResponse { ImIntegration integration = 1; } + +message UpdateIntegrationRequest { + string integration_id = 1; + optional string sync_direction = 2; + optional bool active = 3; +} +message UpdateIntegrationResponse { ImIntegration integration = 1; } + +message DeleteIntegrationRequest { string integration_id = 1; } +message DeleteIntegrationResponse {} + +service ImIntegrationService { + rpc ListIntegrations(ListIntegrationsRequest) returns (ListIntegrationsResponse); + rpc CreateIntegration(CreateIntegrationRequest) returns (CreateIntegrationResponse); + rpc UpdateIntegration(UpdateIntegrationRequest) returns (UpdateIntegrationResponse); + rpc DeleteIntegration(DeleteIntegrationRequest) returns (DeleteIntegrationResponse); +} + + +message CustomEmoji { + string id = 1; + string workspace_id = 2; + string name = 3; + string image_url = 4; + google.protobuf.Timestamp created_at = 5; +} + +message ListCustomEmojisRequest { string workspace_id = 1; } +message ListCustomEmojisResponse { repeated CustomEmoji emojis = 1; } + +message CreateCustomEmojiRequest { + string workspace_id = 1; + string name = 2; + string image_url = 3; +} +message CreateCustomEmojiResponse { CustomEmoji emoji = 1; } + +message DeleteCustomEmojiRequest { string emoji_id = 1; } +message DeleteCustomEmojiResponse {} + +service CustomEmojiService { + rpc ListCustomEmojis(ListCustomEmojisRequest) returns (ListCustomEmojisResponse); + rpc CreateCustomEmoji(CreateCustomEmojiRequest) returns (CreateCustomEmojiResponse); + rpc DeleteCustomEmoji(DeleteCustomEmojiRequest) returns (DeleteCustomEmojiResponse); +} + + +message ForumTag { + string id = 1; + string channel_id = 2; + string name = 3; + bool moderated = 4; + int32 position = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +message ListForumTagsRequest { string channel_id = 1; } +message ListForumTagsResponse { repeated ForumTag tags = 1; } + +message CreateForumTagRequest { + string channel_id = 1; + string name = 2; + bool moderated = 3; + optional int32 position = 4; +} +message CreateForumTagResponse { ForumTag tag = 1; } + +message UpdateForumTagRequest { + string tag_id = 1; + optional string name = 2; + optional bool moderated = 3; + optional int32 position = 4; +} +message UpdateForumTagResponse { ForumTag tag = 1; } + +message DeleteForumTagRequest { string tag_id = 1; } +message DeleteForumTagResponse {} + +service ForumTagService { + rpc ListForumTags(ListForumTagsRequest) returns (ListForumTagsResponse); + rpc CreateForumTag(CreateForumTagRequest) returns (CreateForumTagResponse); + rpc UpdateForumTag(UpdateForumTagRequest) returns (UpdateForumTagResponse); + rpc DeleteForumTag(DeleteForumTagRequest) returns (DeleteForumTagResponse); +} + + +message VoiceParticipant { + string id = 1; + string channel_id = 2; + string user_id = 3; + bool muted = 4; + bool deafened = 5; + google.protobuf.Timestamp joined_at = 6; +} + +message ListVoiceParticipantsRequest { string channel_id = 1; } +message ListVoiceParticipantsResponse { repeated VoiceParticipant participants = 1; } + +message UpdateVoiceStateRequest { + string channel_id = 1; + string user_id = 2; + optional bool muted = 3; + optional bool deafened = 4; +} +message UpdateVoiceStateResponse { VoiceParticipant participant = 1; } + +service VoiceService { + rpc ListVoiceParticipants(ListVoiceParticipantsRequest) returns (ListVoiceParticipantsResponse); + rpc UpdateVoiceState(UpdateVoiceStateRequest) returns (UpdateVoiceStateResponse); +} + + +message Stage { + string id = 1; + string channel_id = 2; + string topic = 3; + string privacy_level = 4; + bool discoverable = 5; + google.protobuf.Timestamp started_at = 6; + google.protobuf.Timestamp ended_at = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +message GetStageRequest { string channel_id = 1; } +message GetStageResponse { Stage stage = 1; } + +message CreateStageRequest { + string channel_id = 1; + string topic = 2; + string privacy_level = 3; + bool discoverable = 4; +} +message CreateStageResponse { Stage stage = 1; } + +message UpdateStageRequest { + string stage_id = 1; + optional string topic = 2; + optional string privacy_level = 3; + optional bool discoverable = 4; +} +message UpdateStageResponse { Stage stage = 1; } + +message DeleteStageRequest { string stage_id = 1; } +message DeleteStageResponse {} + +service StageService { + rpc GetStage(GetStageRequest) returns (GetStageResponse); + rpc CreateStage(CreateStageRequest) returns (CreateStageResponse); + rpc UpdateStage(UpdateStageRequest) returns (UpdateStageResponse); + rpc DeleteStage(DeleteStageRequest) returns (DeleteStageResponse); +} + + +message ChannelAuditEvent { + string id = 1; + string channel_id = 2; + string actor_id = 3; + string event_type = 4; + string target_type = 5; + string target_id = 6; + optional string old_value = 7; + optional string new_value = 8; + google.protobuf.Timestamp created_at = 9; +} + +message ListChannelEventsRequest { + string channel_id = 1; + int32 limit = 2; + int32 offset = 3; +} +message ListChannelEventsResponse { + repeated ChannelAuditEvent events = 1; + int32 total = 2; +} + +service ChannelAuditService { + rpc ListChannelEvents(ListChannelEventsRequest) returns (ListChannelEventsResponse); +} diff --git a/proto/core/member.proto b/proto/core/member.proto new file mode 100644 index 0000000..bde3bf4 --- /dev/null +++ b/proto/core/member.proto @@ -0,0 +1,126 @@ +syntax = "proto3"; + +package appks.im.v1; + +import "google/protobuf/timestamp.proto"; + +// Member management service for the IM microservice. +// Provides CRUD for channel members, join/leave, and membership checks. + +enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_OWNER = 1; + ROLE_ADMIN = 2; + ROLE_MAINTAINER = 3; + ROLE_MODERATOR = 4; + ROLE_MEMBER = 5; + ROLE_CONTRIBUTOR = 6; + ROLE_VIEWER = 7; + ROLE_GUEST = 8; + ROLE_BOT = 9; +} + +enum MemberStatus { + MEMBER_STATUS_UNSPECIFIED = 0; + MEMBER_STATUS_ACTIVE = 1; + MEMBER_STATUS_INVITED = 2; + MEMBER_STATUS_LEFT = 3; + MEMBER_STATUS_KICKED = 4; + MEMBER_STATUS_BANNED = 5; +} + + +message ChannelMember { + string id = 1; + string channel_id = 2; + string user_id = 3; + string role = 4; + string status = 5; + bool muted = 6; + bool pinned = 7; + optional string last_read_message_id = 8; + optional google.protobuf.Timestamp last_read_at = 9; + optional google.protobuf.Timestamp joined_at = 10; + optional google.protobuf.Timestamp left_at = 11; + google.protobuf.Timestamp created_at = 12; + google.protobuf.Timestamp updated_at = 13; +} + + +message ListMembersRequest { + string channel_id = 1; + optional string status = 2; + int32 limit = 3; + int32 offset = 4; +} + +message ListMembersResponse { + repeated ChannelMember members = 1; + int32 total = 2; +} + +message InviteMemberRequest { + string channel_id = 1; + string user_id = 2; + optional string role = 3; +} + +message InviteMemberResponse { + ChannelMember member = 1; +} + +message UpdateMemberRequest { + string channel_id = 1; + string user_id = 2; + optional string role = 3; + optional bool muted = 4; + optional bool pinned = 5; +} + +message UpdateMemberResponse { + ChannelMember member = 1; +} + +message KickMemberRequest { + string channel_id = 1; + string user_id = 2; +} + +message KickMemberResponse {} + +message JoinChannelRequest { + string channel_id = 1; + string user_id = 2; +} + +message JoinChannelResponse { + ChannelMember member = 1; +} + +message LeaveChannelRequest { + string channel_id = 1; + string user_id = 2; +} + +message LeaveChannelResponse {} + +message IsMemberRequest { + string channel_id = 1; + string user_id = 2; +} + +message IsMemberResponse { + bool is_member = 1; + string role = 2; +} + + +service MemberService { + rpc ListMembers(ListMembersRequest) returns (ListMembersResponse); + rpc InviteMember(InviteMemberRequest) returns (InviteMemberResponse); + rpc UpdateMember(UpdateMemberRequest) returns (UpdateMemberResponse); + rpc KickMember(KickMemberRequest) returns (KickMemberResponse); + rpc JoinChannel(JoinChannelRequest) returns (JoinChannelResponse); + rpc LeaveChannel(LeaveChannelRequest) returns (LeaveChannelResponse); + rpc IsMember(IsMemberRequest) returns (IsMemberResponse); +} diff --git a/proto/core/permission.proto b/proto/core/permission.proto new file mode 100644 index 0000000..a8505d6 --- /dev/null +++ b/proto/core/permission.proto @@ -0,0 +1,125 @@ +syntax = "proto3"; + +package appks.im.v1; + +// IM-specific permissions for channel operations. +// Separate from the general Permission enum used for repo/workspace access. +enum ImPermission { + IM_PERMISSION_UNSPECIFIED = 0; + IM_PERMISSION_READ_CHANNEL = 1; + IM_PERMISSION_SEND_MESSAGE = 2; + IM_PERMISSION_MANAGE_THREADS = 3; + IM_PERMISSION_MANAGE_REACTIONS = 4; + IM_PERMISSION_MANAGE_PINS = 5; + IM_PERMISSION_INVITE_MEMBERS = 6; + IM_PERMISSION_KICK_MEMBERS = 7; + IM_PERMISSION_MANAGE_CHANNEL = 8; + IM_PERMISSION_MANAGE_ROLES = 9; + IM_PERMISSION_MANAGE_WEBHOOKS = 10; + IM_PERMISSION_MANAGE_EMOJIS = 11; + IM_PERMISSION_VIEW_AUDIT_LOG = 12; + IM_PERMISSION_MANAGE_INTEGRATIONS = 13; + IM_PERMISSION_SEND_TTS = 14; + IM_PERMISSION_USE_SLASH_COMMANDS = 15; + IM_PERMISSION_ATTACH_FILES = 16; + IM_PERMISSION_MENTION_EVERYONE = 17; + IM_PERMISSION_MANAGE_MESSAGES = 18; + IM_PERMISSION_ADMIN = 19; +} + + +message PermissionOverwrite { + string id = 1; + string channel_id = 2; + string target_type = 3; + string target_id = 4; + repeated ImPermission allow = 5; + repeated ImPermission deny = 6; + string created_at = 7; + string updated_at = 8; +} + + +message CheckPermissionRequest { + string channel_id = 1; + string user_id = 2; + ImPermission permission = 3; +} + +message CheckPermissionResponse { + bool allowed = 1; + string role = 2; +} + +message GetPermissionsRequest { + string channel_id = 1; + string user_id = 2; +} + +message GetPermissionsResponse { + repeated ImPermission permissions = 1; + string role = 2; +} + +message SetPermissionOverwriteRequest { + string channel_id = 1; + string target_type = 2; + string target_id = 3; + repeated ImPermission allow = 4; + repeated ImPermission deny = 5; +} + +message SetPermissionOverwriteResponse { + PermissionOverwrite overwrite = 1; +} + +message GetPermissionOverwritesRequest { + string channel_id = 1; +} + +message GetPermissionOverwritesResponse { + repeated PermissionOverwrite overwrites = 1; +} + +message DeletePermissionOverwriteRequest { + string channel_id = 1; + string target_type = 2; + string target_id = 3; +} + +message DeletePermissionOverwriteResponse {} + +message ResolveChannelRequest { + string channel_id = 1; +} + +message ResolveChannelResponse { + string channel_id = 1; + string workspace_id = 2; + string name = 3; + string visibility = 4; + string channel_type = 5; + bool read_only = 6; + bool archived = 7; + optional string created_by = 8; +} + +message EnsureReadableRequest { + string channel_id = 1; + string user_id = 2; +} + +message EnsureReadableResponse { + bool allowed = 1; +} + + +service PermissionService { + rpc CheckPermission(CheckPermissionRequest) returns (CheckPermissionResponse); + rpc GetPermissions(GetPermissionsRequest) returns (GetPermissionsResponse); + rpc SetPermissionOverwrite(SetPermissionOverwriteRequest) returns (SetPermissionOverwriteResponse); + rpc GetPermissionOverwrites(GetPermissionOverwritesRequest) returns (GetPermissionOverwritesResponse); + rpc DeletePermissionOverwrite(DeletePermissionOverwriteRequest) returns (DeletePermissionOverwriteResponse); + rpc ResolveChannel(ResolveChannelRequest) returns (ResolveChannelResponse); + rpc EnsureReadable(EnsureReadableRequest) returns (EnsureReadableResponse); +} diff --git a/socket/adapter/local.rs b/socket/adapter/local.rs new file mode 100644 index 0000000..daab46a --- /dev/null +++ b/socket/adapter/local.rs @@ -0,0 +1,199 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use uuid::Uuid; + +use crate::socket::adapter::{Adapter, AdapterError, BroadcastOptions, SocketInfo}; +use crate::socket::packet::Packet; + +pub struct LocalAdapter { + server_id: String, + rooms: Arc>>, + socket_rooms: Arc>>, + /// socket_sid → engine_sid + pub socket_sids: Arc>, + /// socket_sid → namespace path + socket_namespace: Arc>, + send_fn: Arc Result<(), String> + Send + Sync>, +} + +impl LocalAdapter { + pub fn new( + send_fn: impl Fn(&str, &Packet) -> Result<(), String> + Send + Sync + 'static, + ) -> Self { + Self { + server_id: Uuid::new_v4().to_string(), + rooms: Arc::new(DashMap::new()), + socket_rooms: Arc::new(DashMap::new()), + socket_sids: Arc::new(DashMap::new()), + socket_namespace: Arc::new(DashMap::new()), + send_fn: Arc::new(send_fn), + } + } + + fn room_key(ns: &str, room: &str) -> String { + format!("{}:{}", ns, room) + } + + /// Collect socket SIDs matching the broadcast options, scoped to the given namespace. + fn collect_matching_sids(&self, opts: &BroadcastOptions, namespace: &str) -> Vec { + if opts.rooms.is_empty() { + // Broadcast to all sockets in this namespace only + self.socket_sids + .iter() + .filter(|e| { + self.socket_namespace + .get(e.key()) + .map(|ns| ns.value() == namespace) + .unwrap_or(false) + }) + .map(|e| e.key().clone()) + .collect() + } else { + let mut sids = HashSet::new(); + for room in &opts.rooms { + let key = Self::room_key(namespace, room); + if let Some(entry) = self.rooms.get(&key) { + for sid in entry.value() { + sids.insert(sid.clone()); + } + } + } + sids.into_iter().collect() + } + } +} + +#[async_trait] +impl Adapter for LocalAdapter { + async fn broadcast(&self, packet: &Packet, opts: &BroadcastOptions) -> Result<(), AdapterError> { + let namespace = &packet.namespace; + let sids = self.collect_matching_sids(opts, namespace); + for sid in &sids { + if opts.except.contains(sid) { + continue; + } + // socket_sids maps socket SID -> engine SID + if let Some(entry) = self.socket_sids.get(sid) { + let engine_sid = entry.value(); + let result = (self.send_fn)(engine_sid, packet); + if let Err(e) = result { + tracing::warn!("Failed to broadcast to {}: {}", sid, e); + } + } + } + Ok(()) + } + + async fn register(&self, socket_sid: &str, engine_sid: &str, ns: &str) -> Result<(), AdapterError> { + self.socket_sids.insert(socket_sid.to_string(), engine_sid.to_string()); + self.socket_namespace.insert(socket_sid.to_string(), ns.to_string()); + Ok(()) + } + + async fn unregister(&self, socket_sid: &str, ns: &str) -> Result<(), AdapterError> { + self.del_all(socket_sid, ns).await + } + + async fn add(&self, sid: &str, room: &str, ns: &str) -> Result<(), AdapterError> { + let key = Self::room_key(ns, room); + self.rooms.entry(key).or_insert_with(HashSet::new).value_mut().insert(sid.to_string()); + self.socket_rooms.entry(sid.to_string()).or_insert_with(HashSet::new).value_mut().insert(room.to_string()); + Ok(()) + } + + async fn del(&self, sid: &str, room: &str, ns: &str) -> Result<(), AdapterError> { + let key = Self::room_key(ns, room); + if let Some(mut room_sids) = self.rooms.get_mut(&key) { + room_sids.value_mut().remove(sid); + if room_sids.value_mut().is_empty() { + drop(room_sids); + self.rooms.remove(&key); + } + } + if let Some(mut rooms) = self.socket_rooms.get_mut(sid) { + rooms.value_mut().remove(room); + if rooms.value_mut().is_empty() { + drop(rooms); + self.socket_rooms.remove(sid); + } + } + Ok(()) + } + + async fn del_all(&self, sid: &str, ns: &str) -> Result<(), AdapterError> { + if let Some((_, rooms)) = self.socket_rooms.remove(sid) { + for room in &rooms { + let key = Self::room_key(ns, room); + if let Some(mut room_sids) = self.rooms.get_mut(&key) { + room_sids.value_mut().remove(sid); + if room_sids.value_mut().is_empty() { + drop(room_sids); + self.rooms.remove(&key); + } + } + } + } + self.socket_sids.remove(sid); + Ok(()) + } + + async fn fetch_sockets(&self, opts: &BroadcastOptions) -> Result, AdapterError> { + // fetch_sockets needs namespace context; use an empty namespace to match all + // (this method is typically called for inspection, not delivery) + let sids: Vec = if opts.rooms.is_empty() { + self.socket_sids.iter().map(|e| e.key().clone()).collect() + } else { + let mut sids_set = HashSet::new(); + for room in &opts.rooms { + for entry in self.rooms.iter() { + if entry.key().ends_with(&format!(":{}", room)) { + for sid in entry.value() { + sids_set.insert(sid.clone()); + } + } + } + } + sids_set.into_iter().collect() + }; + let mut result = Vec::new(); + for sid in &sids { + if opts.except.contains(sid) { + continue; + } + if self.socket_sids.contains_key(sid) { + let namespace = self.socket_namespace + .get(sid) + .map(|r| r.value().clone()) + .unwrap_or_default(); + let rooms = self.socket_rooms + .get(sid) + .map(|r| r.value().clone()) + .unwrap_or_default(); + result.push(SocketInfo { + sid: sid.clone(), + namespace, + rooms, + }); + } + } + Ok(result) + } + + async fn socket_rooms(&self, sid: &str) -> Result, AdapterError> { + Ok(self.socket_rooms + .get(sid) + .map(|r| r.value().clone()) + .unwrap_or_default()) + } + + fn server_id(&self) -> &str { + &self.server_id + } + + async fn close(&self) -> Result<(), AdapterError> { + Ok(()) + } +} \ No newline at end of file diff --git a/socket/adapter/mod.rs b/socket/adapter/mod.rs new file mode 100644 index 0000000..b46eeed --- /dev/null +++ b/socket/adapter/mod.rs @@ -0,0 +1,99 @@ +pub mod local; +pub mod redis; +pub mod nats; + +use std::collections::HashSet; + +use async_trait::async_trait; +use thiserror::Error; + +use crate::socket::packet::Packet; + +#[derive(Error, Debug)] +pub enum AdapterError { + #[error("Redis error: {0}")] + Redis(String), + #[error("NATS error: {0}")] + Nats(String), + #[error("Message bus error: {0}")] + MessageBus(String), + #[error("Serialization error: {0}")] + Serialization(String), + #[error("Room error: {0}")] + Room(String), +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct BroadcastOptions { + pub rooms: HashSet, + pub except: HashSet, + pub flags: BroadcastFlags, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct BroadcastFlags { + pub local_only: bool, + pub broadcast: bool, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SocketInfo { + pub sid: String, + pub namespace: String, + pub rooms: HashSet, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub enum BusMessage { + Broadcast { + namespace: String, + packet: String, + opts: BroadcastOptions, + server_id: String, + }, + SocketJoin { + namespace: String, + sid: String, + room: String, + server_id: String, + }, + SocketLeave { + namespace: String, + sid: String, + room: String, + server_id: String, + }, + SocketDisconnect { + namespace: String, + sid: String, + server_id: String, + }, +} + +#[async_trait] +pub trait Adapter: Send + Sync + 'static { + async fn broadcast(&self, packet: &Packet, opts: &BroadcastOptions) -> Result<(), AdapterError>; + async fn add(&self, sid: &str, room: &str, ns: &str) -> Result<(), AdapterError>; + async fn del(&self, sid: &str, room: &str, ns: &str) -> Result<(), AdapterError>; + async fn del_all(&self, sid: &str, ns: &str) -> Result<(), AdapterError>; + async fn fetch_sockets(&self, opts: &BroadcastOptions) -> Result, AdapterError>; + async fn socket_rooms(&self, sid: &str) -> Result, AdapterError>; + fn server_id(&self) -> &str; + async fn close(&self) -> Result<(), AdapterError>; + + /// Register a socket SID → engine SID mapping in the adapter. + /// Must be called when a socket first connects, before any room operations. + /// The `ns` parameter is the namespace path this socket belongs to. + async fn register(&self, _socket_sid: &str, _engine_sid: &str, _ns: &str) -> Result<(), AdapterError> { + Ok(()) + } + + /// Unregister a socket from the adapter, removing all local mappings. + async fn unregister(&self, _socket_sid: &str, _ns: &str) -> Result<(), AdapterError> { + Ok(()) + } +} + +pub use local::LocalAdapter; +pub use redis::RedisAdapter; +pub use nats::NatsAdapter; \ No newline at end of file diff --git a/socket/adapter/nats.rs b/socket/adapter/nats.rs new file mode 100644 index 0000000..8cd86c1 --- /dev/null +++ b/socket/adapter/nats.rs @@ -0,0 +1,302 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use tokio::sync::mpsc; + +use crate::socket::adapter::{Adapter, AdapterError, BroadcastOptions, BusMessage, SocketInfo}; +use crate::socket::message_bus::MessageBus; +use crate::socket::packet::Packet; +use crate::socket::parser; +use crate::socket::socket::Socket; + +/// Handle incoming bus messages from other servers. +/// Only performs local dispatch — no remote state writes needed. +async fn handle_bus_message( + msg: BusMessage, + on_local_broadcast: &Arc, + server_id: &str, +) { + match msg { + BusMessage::Broadcast { namespace: _, packet, opts, server_id: sender_id } => { + if sender_id == server_id { + return; + } + if let Ok(decoded_packet) = parser::decode(&packet) { + on_local_broadcast(&decoded_packet, &opts); + } + } + // NATS adapter manages room state locally; cross-server join/leave/disconnect + // are informational only and don't require duplicate state writes. + BusMessage::SocketJoin { server_id: sender_id, .. } + | BusMessage::SocketLeave { server_id: sender_id, .. } + | BusMessage::SocketDisconnect { server_id: sender_id, .. } => { + if sender_id == server_id { + return; + } + } + } +} + +/// NATS-based adapter that manages room state locally and uses NATS +/// for cross-server broadcast only. Does NOT depend on Redis. +pub struct NatsAdapter { + message_bus: Arc, + room_subscribers: DashMap>>, + socket_rooms: DashMap>, + rooms: DashMap>, + /// socket_sid → engine_sid mapping for local delivery + socket_sids: DashMap, + sockets: DashMap>, + server_id: String, + namespace: String, + on_local_broadcast: Arc, +} + +impl NatsAdapter { + pub fn new( + message_bus: Arc, + server_id: String, + namespace: String, + on_local_broadcast: Arc, + ) -> Self { + Self { + message_bus, + server_id, + namespace, + on_local_broadcast, + room_subscribers: DashMap::new(), + socket_rooms: DashMap::new(), + rooms: DashMap::new(), + socket_sids: DashMap::new(), + sockets: DashMap::new(), + } + } + + pub async fn init(&self) -> Result<(), AdapterError> { + let channels = ["broadcast", "join", "leave", "disconnect"]; + let prefix = format!("socket.io:{}:", self.namespace); + + for channel_type in channels { + let subject = format!("{}{}", prefix, channel_type); + match self.message_bus.subscribe(&subject).await { + Ok(rx) => { + self.room_subscribers.insert(channel_type.to_string(), rx); + } + Err(e) => return Err(AdapterError::MessageBus(e.to_string())), + } + } + + self.spawn_listener(); + Ok(()) + } + + fn spawn_listener(&self) { + let server_id = self.server_id.clone(); + let on_local_broadcast = self.on_local_broadcast.clone(); + + let mut broadcast_rx = self.room_subscribers.remove("broadcast").map(|(_, rx)| rx); + let mut join_rx = self.room_subscribers.remove("join").map(|(_, rx)| rx); + let mut leave_rx = self.room_subscribers.remove("leave").map(|(_, rx)| rx); + let mut disconnect_rx = self.room_subscribers.remove("disconnect").map(|(_, rx)| rx); + + tokio::spawn(async move { + loop { + tokio::select! { + Some(data) = async { broadcast_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + Some(data) = async { join_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + Some(data) = async { leave_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + Some(data) = async { disconnect_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + else => break, + } + } + }); + } +} + +#[async_trait] +impl Adapter for NatsAdapter { + async fn broadcast(&self, packet: &Packet, opts: &BroadcastOptions) -> Result<(), AdapterError> { + if opts.flags.local_only { + (self.on_local_broadcast)(packet, opts); + return Ok(()); + } + + let msg = BusMessage::Broadcast { + namespace: self.namespace.clone(), + packet: parser::encode(packet), + opts: opts.clone(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:broadcast", self.namespace), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + (self.on_local_broadcast)(packet, opts); + Ok(()) + } + + async fn register(&self, socket_sid: &str, engine_sid: &str, _ns: &str) -> Result<(), AdapterError> { + self.socket_sids.insert(socket_sid.to_string(), engine_sid.to_string()); + Ok(()) + } + + async fn add(&self, sid: &str, room: &str, _ns: &str) -> Result<(), AdapterError> { + self.socket_rooms + .entry(sid.to_string()) + .and_modify(|set| { set.insert(room.to_string()); }) + .or_insert_with(|| HashSet::from([room.to_string()])); + + self.rooms + .entry(room.to_string()) + .and_modify(|set| { set.insert(sid.to_string()); }) + .or_insert_with(|| HashSet::from([sid.to_string()])); + + let msg = BusMessage::SocketJoin { + namespace: self.namespace.clone(), + sid: sid.to_string(), + room: room.to_string(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:join", self.namespace), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + Ok(()) + } + + async fn del(&self, sid: &str, room: &str, _ns: &str) -> Result<(), AdapterError> { + if let Some(mut entry) = self.socket_rooms.get_mut(sid) { + entry.value_mut().remove(room); + } + if self.socket_rooms.get(sid).map(|e| e.value().is_empty()).unwrap_or(true) { + self.socket_rooms.remove(sid); + } + + if let Some(mut entry) = self.rooms.get_mut(room) { + entry.value_mut().remove(sid); + } + if self.rooms.get(room).map(|e| e.value().is_empty()).unwrap_or(true) { + self.rooms.remove(room); + } + + let msg = BusMessage::SocketLeave { + namespace: self.namespace.clone(), + sid: sid.to_string(), + room: room.to_string(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:leave", self.namespace), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + Ok(()) + } + + async fn del_all(&self, sid: &str, _ns: &str) -> Result<(), AdapterError> { + if let Some((_, rooms)) = self.socket_rooms.remove(sid) { + for room in &rooms { + if let Some(mut entry) = self.rooms.get_mut(room) { + entry.value_mut().remove(sid); + } + if self.rooms.get(room).map(|e| e.value().is_empty()).unwrap_or(true) { + self.rooms.remove(room); + } + } + } + + self.socket_sids.remove(sid); + self.sockets.remove(sid); + + let msg = BusMessage::SocketDisconnect { + namespace: self.namespace.clone(), + sid: sid.to_string(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:disconnect", self.namespace), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + Ok(()) + } + + async fn fetch_sockets(&self, opts: &BroadcastOptions) -> Result, AdapterError> { + let mut result = Vec::new(); + + let target_sids: HashSet = if opts.rooms.is_empty() { + self.socket_sids.iter().map(|e| e.key().clone()).collect() + } else { + let mut sids = HashSet::new(); + for room in &opts.rooms { + if let Some(entry) = self.rooms.get(room) { + sids.extend(entry.value().iter().cloned()); + } + } + sids + }; + + for sid in target_sids { + if opts.except.contains(&sid) { + continue; + } + let rooms = self.socket_rooms.get(&sid).map(|e| e.value().clone()).unwrap_or_default(); + result.push(SocketInfo { + sid: sid.clone(), + namespace: self.namespace.clone(), + rooms, + }); + } + + Ok(result) + } + + async fn socket_rooms(&self, sid: &str) -> Result, AdapterError> { + Ok(self.socket_rooms.get(sid).map(|e| e.value().clone()).unwrap_or_default()) + } + + fn server_id(&self) -> &str { + &self.server_id + } + + async fn close(&self) -> Result<(), AdapterError> { + self.message_bus.close().await.map_err(|e| AdapterError::MessageBus(e.to_string()))?; + Ok(()) + } +} diff --git a/socket/adapter/redis.rs b/socket/adapter/redis.rs new file mode 100644 index 0000000..98f4591 --- /dev/null +++ b/socket/adapter/redis.rs @@ -0,0 +1,344 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use fred::clients::Client; +use fred::interfaces::{KeysInterface, SetsInterface}; +use tokio::sync::mpsc; + +use crate::socket::adapter::{Adapter, AdapterError, BroadcastOptions, BusMessage, SocketInfo}; +use crate::socket::message_bus::MessageBus; +use crate::socket::packet::Packet; +use crate::socket::parser; +use crate::socket::socket::Socket; + +const KEY_PREFIX_ROOMS: &str = "socket.io:rooms"; +const KEY_PREFIX_SOCKET_ROOMS: &str = "socket.io:socket_rooms"; + +fn room_key(ns: &str, room: &str) -> String { + format!("{}:{}:{}", KEY_PREFIX_ROOMS, ns, room) +} + +fn socket_rooms_key(ns: &str, sid: &str) -> String { + format!("{}:{}:{}", KEY_PREFIX_SOCKET_ROOMS, ns, sid) +} + +/// Handle incoming bus messages from other servers. +/// Only performs local state updates — the remote server already wrote to Redis. +async fn handle_bus_message( + msg: BusMessage, + on_local_broadcast: &Arc, + server_id: &str, +) { + match msg { + BusMessage::Broadcast { namespace: _, packet, opts, server_id: sender_id } => { + if sender_id == server_id { + return; + } + if let Ok(decoded_packet) = parser::decode(&packet) { + on_local_broadcast(&decoded_packet, &opts); + } + } + BusMessage::SocketJoin { server_id: sender_id, .. } + | BusMessage::SocketLeave { server_id: sender_id, .. } + | BusMessage::SocketDisconnect { server_id: sender_id, .. } => { + // Skip messages from this server; remote server already updated Redis + if sender_id == server_id { + return; + } + // No duplicate Redis writes — the sender already persisted the state change + } + } +} + +pub struct RedisAdapter { + message_bus: Arc, + redis_client: Client, + room_subscribers: DashMap>>, + socket_rooms: DashMap>, + rooms: DashMap>, + sockets: DashMap>, + server_id: String, + namespace: String, + on_local_broadcast: Arc, +} + +impl RedisAdapter { + pub fn new( + message_bus: Arc, + redis_client: Client, + server_id: String, + namespace: String, + on_local_broadcast: Arc, + ) -> Self { + Self { + message_bus, + redis_client, + server_id, + namespace, + on_local_broadcast, + room_subscribers: DashMap::new(), + socket_rooms: DashMap::new(), + rooms: DashMap::new(), + sockets: DashMap::new(), + } + } + + pub async fn init(&self) -> Result<(), AdapterError> { + let channels = ["broadcast", "join", "leave", "disconnect"]; + let prefix = format!("socket.io:{}:", self.namespace); + + for channel_type in channels { + let channel = format!("{}{}", prefix, channel_type); + match self.message_bus.subscribe(&channel).await { + Ok(rx) => { + self.room_subscribers.insert(channel_type.to_string(), rx); + } + Err(e) => return Err(AdapterError::MessageBus(e.to_string())), + } + } + + self.spawn_listener(); + Ok(()) + } + + fn spawn_listener(&self) { + let server_id = self.server_id.clone(); + let on_local_broadcast = self.on_local_broadcast.clone(); + + let mut broadcast_rx = self.room_subscribers.remove("broadcast").map(|(_, rx)| rx); + let mut join_rx = self.room_subscribers.remove("join").map(|(_, rx)| rx); + let mut leave_rx = self.room_subscribers.remove("leave").map(|(_, rx)| rx); + let mut disconnect_rx = self.room_subscribers.remove("disconnect").map(|(_, rx)| rx); + + tokio::spawn(async move { + loop { + tokio::select! { + Some(data) = async { broadcast_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + Some(data) = async { join_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + Some(data) = async { leave_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + Some(data) = async { disconnect_rx.as_mut()?.recv().await } => { + if let Ok(msg) = serde_json::from_slice::(&data) { + handle_bus_message(msg, &on_local_broadcast, &server_id).await; + } + } + else => break, + } + } + }); + } +} + +#[async_trait] +impl Adapter for RedisAdapter { + async fn broadcast(&self, packet: &Packet, opts: &BroadcastOptions) -> Result<(), AdapterError> { + if opts.flags.local_only { + (self.on_local_broadcast)(packet, opts); + return Ok(()); + } + + let msg = BusMessage::Broadcast { + namespace: packet.namespace.clone(), + packet: parser::encode(packet), + opts: opts.clone(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:broadcast", packet.namespace), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + (self.on_local_broadcast)(packet, opts); + Ok(()) + } + + async fn add(&self, sid: &str, room: &str, ns: &str) -> Result<(), AdapterError> { + let rk = room_key(ns, room); + let srk = socket_rooms_key(ns, sid); + + self.redis_client + .sadd::<(), _, _>(&rk, sid) + .await + .map_err(|e| AdapterError::Redis(e.to_string()))?; + + self.redis_client + .sadd::<(), _, _>(&srk, room) + .await + .map_err(|e| AdapterError::Redis(e.to_string()))?; + + self.socket_rooms + .entry(sid.to_string()) + .and_modify(|set| { set.insert(room.to_string()); }) + .or_insert_with(|| HashSet::from([room.to_string()])); + + self.rooms + .entry(room.to_string()) + .and_modify(|set| { set.insert(sid.to_string()); }) + .or_insert_with(|| HashSet::from([sid.to_string()])); + + let msg = BusMessage::SocketJoin { + namespace: ns.to_string(), + sid: sid.to_string(), + room: room.to_string(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:join", ns), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + Ok(()) + } + + async fn del(&self, sid: &str, room: &str, ns: &str) -> Result<(), AdapterError> { + let rk = room_key(ns, room); + let srk = socket_rooms_key(ns, sid); + + self.redis_client + .srem::<(), _, _>(&rk, sid) + .await + .map_err(|e| AdapterError::Redis(e.to_string()))?; + + self.redis_client + .srem::<(), _, _>(&srk, room) + .await + .map_err(|e| AdapterError::Redis(e.to_string()))?; + + if let Some(mut entry) = self.socket_rooms.get_mut(sid) { + entry.value_mut().remove(room); + } + if self.socket_rooms.get(sid).map(|e| e.value().is_empty()).unwrap_or(true) { + self.socket_rooms.remove(sid); + } + + if let Some(mut entry) = self.rooms.get_mut(room) { + entry.value_mut().remove(sid); + } + if self.rooms.get(room).map(|e| e.value().is_empty()).unwrap_or(true) { + self.rooms.remove(room); + } + + let msg = BusMessage::SocketLeave { + namespace: ns.to_string(), + sid: sid.to_string(), + room: room.to_string(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:leave", ns), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + Ok(()) + } + + async fn del_all(&self, sid: &str, ns: &str) -> Result<(), AdapterError> { + if let Some((_, rooms)) = self.socket_rooms.remove(sid) { + for room in &rooms { + if let Some(mut entry) = self.rooms.get_mut(room) { + entry.value_mut().remove(sid); + } + if self.rooms.get(room).map(|e| e.value().is_empty()).unwrap_or(true) { + self.rooms.remove(room); + } + + let rk = room_key(ns, room); + if let Err(e) = self.redis_client.srem::<(), _, _>(&rk, sid).await { + tracing::warn!("Redis SREM room error: {}", e); + } + } + } + + let srk = socket_rooms_key(ns, sid); + self.redis_client + .del::<(), _>(&srk) + .await + .map_err(|e| AdapterError::Redis(e.to_string()))?; + + self.sockets.remove(sid); + + let msg = BusMessage::SocketDisconnect { + namespace: ns.to_string(), + sid: sid.to_string(), + server_id: self.server_id.clone(), + }; + + let payload = serde_json::to_vec(&msg) + .map_err(|e| AdapterError::Serialization(e.to_string()))?; + + self.message_bus + .publish(&format!("socket.io:{}:disconnect", ns), &payload) + .await + .map_err(|e| AdapterError::MessageBus(e.to_string()))?; + + Ok(()) + } + + async fn fetch_sockets(&self, opts: &BroadcastOptions) -> Result, AdapterError> { + let mut result = Vec::new(); + + let target_sids: HashSet = if opts.rooms.is_empty() { + self.sockets.iter().map(|e| e.key().clone()).collect() + } else { + let mut sids = HashSet::new(); + for room in &opts.rooms { + if let Some(entry) = self.rooms.get(room) { + sids.extend(entry.value().iter().cloned()); + } + } + sids + }; + + for sid in target_sids { + if opts.except.contains(&sid) { + continue; + } + let rooms = self.socket_rooms.get(&sid).map(|e| e.value().clone()).unwrap_or_default(); + result.push(SocketInfo { + sid: sid.clone(), + namespace: self.namespace.clone(), + rooms, + }); + } + + Ok(result) + } + + async fn socket_rooms(&self, sid: &str) -> Result, AdapterError> { + Ok(self.socket_rooms.get(sid).map(|e| e.value().clone()).unwrap_or_default()) + } + + fn server_id(&self) -> &str { + &self.server_id + } + + async fn close(&self) -> Result<(), AdapterError> { + self.message_bus.close().await.map_err(|e| AdapterError::MessageBus(e.to_string()))?; + Ok(()) + } +} diff --git a/socket/message_bus/mod.rs b/socket/message_bus/mod.rs new file mode 100644 index 0000000..b9c79f2 --- /dev/null +++ b/socket/message_bus/mod.rs @@ -0,0 +1,31 @@ +pub mod redis; +pub mod nats; + +use async_trait::async_trait; +use thiserror::Error; +use tokio::sync::mpsc; + +#[derive(Error, Debug)] +pub enum MessageBusError { + #[error("Redis error: {0}")] + Redis(String), + #[error("NATS error: {0}")] + Nats(String), + #[error("Connection closed")] + ConnectionClosed, + #[error("Channel not found: {0}")] + ChannelNotFound(String), + #[error("Serialization error: {0}")] + Serialization(String), +} + +#[async_trait] +pub trait MessageBus: Send + Sync + 'static { + async fn publish(&self, channel: &str, message: &[u8]) -> Result<(), MessageBusError>; + async fn subscribe(&self, channel: &str) -> Result>, MessageBusError>; + async fn unsubscribe(&self, channel: &str) -> Result<(), MessageBusError>; + async fn close(&self) -> Result<(), MessageBusError>; +} + +pub use redis::RedisMessageBus; +pub use nats::NatsMessageBus; \ No newline at end of file diff --git a/socket/message_bus/nats.rs b/socket/message_bus/nats.rs new file mode 100644 index 0000000..08a0ee9 --- /dev/null +++ b/socket/message_bus/nats.rs @@ -0,0 +1,88 @@ +use async_trait::async_trait; +use dashmap::DashMap; +use tokio::sync::{mpsc, watch}; + +use crate::socket::message_bus::{MessageBus, MessageBusError}; + +pub struct NatsMessageBus { + client: async_nats::Client, + shutdowns: DashMap>, +} + +impl NatsMessageBus { + pub async fn new(nats_url: &str) -> Result { + let client = async_nats::connect(nats_url) + .await + .map_err(|e| MessageBusError::Nats(e.to_string()))?; + Ok(Self { + client, + shutdowns: DashMap::new(), + }) + } +} + +#[async_trait] +impl MessageBus for NatsMessageBus { + async fn publish(&self, channel: &str, message: &[u8]) -> Result<(), MessageBusError> { + self.client + .publish(channel.to_string(), message.to_vec().into()) + .await + .map_err(|e| MessageBusError::Nats(e.to_string()))?; + Ok(()) + } + + async fn subscribe(&self, channel: &str) -> Result>, MessageBusError> { + let (tx, rx) = mpsc::channel::>(256); + + let mut subscriber = self.client + .subscribe(channel.to_string()) + .await + .map_err(|e| MessageBusError::Nats(e.to_string()))?; + + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + self.shutdowns.insert(channel.to_string(), shutdown_tx); + + tokio::spawn(async move { + use futures_util::StreamExt; + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + break; + } + message = subscriber.next() => { + match message { + Some(msg) => { + let data = msg.payload.to_vec(); + if tx.send(data).await.is_err() { + break; + } + } + None => break, + } + } + } + } + if let Err(e) = subscriber.unsubscribe().await { + tracing::warn!("NATS unsubscribe error: {}", e); + } + }); + + Ok(rx) + } + + async fn unsubscribe(&self, channel: &str) -> Result<(), MessageBusError> { + if let Some((_, tx)) = self.shutdowns.remove(channel) { + let _ = tx.send(true); + } + Ok(()) + } + + async fn close(&self) -> Result<(), MessageBusError> { + // Signal all subscribers to shutdown + self.shutdowns.iter().for_each(|entry| { + let _ = entry.value().send(true); + }); + self.shutdowns.clear(); + Ok(()) + } +} \ No newline at end of file diff --git a/socket/message_bus/redis.rs b/socket/message_bus/redis.rs new file mode 100644 index 0000000..c7e8c5e --- /dev/null +++ b/socket/message_bus/redis.rs @@ -0,0 +1,99 @@ +use async_trait::async_trait; +use fred::clients::{Client, SubscriberClient}; +use fred::interfaces::{ClientLike, EventInterface, PubsubInterface}; +use fred::prelude::*; +use tokio::sync::mpsc; + +use crate::socket::message_bus::{MessageBus, MessageBusError}; + +pub struct RedisMessageBus { + client: Client, + subscriber: SubscriberClient, +} + +impl RedisMessageBus { + pub async fn new(redis_url: &str) -> Result { + let config = Config::from_url(redis_url) + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + + let client = Client::new(config.clone(), None, None, None); + let subscriber = SubscriberClient::new(config, None, None, None); + + // connect() starts the connection task; result is checked by wait_for_connect() + let _ = client.connect().await; + let _ = subscriber.connect().await; + + client + .wait_for_connect() + .await + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + subscriber + .wait_for_connect() + .await + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + + Ok(Self { client, subscriber }) + } + + pub fn client(&self) -> &Client { + &self.client + } +} + +#[async_trait] +impl MessageBus for RedisMessageBus { + async fn publish(&self, channel: &str, message: &[u8]) -> Result<(), MessageBusError> { + self.client + .publish::<(), _, Vec>(channel, message.to_vec()) + .await + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + Ok(()) + } + + async fn subscribe(&self, channel: &str) -> Result>, MessageBusError> { + let (tx, rx) = mpsc::channel::>(256); + + self.subscriber + .subscribe(channel.to_string()) + .await + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + + let subscriber = self.subscriber.clone(); + let channel_owned = channel.to_string(); + let mut message_rx = subscriber.message_rx(); + + tokio::spawn(async move { + while let Ok(message) = message_rx.recv().await { + if &message.channel == &channel_owned { + let data: Vec = FromValue::from_value(message.value) + .unwrap_or_default(); + if tx.send(data).await.is_err() { + break; + } + } + } + }); + + Ok(rx) + } + + async fn unsubscribe(&self, channel: &str) -> Result<(), MessageBusError> { + self.subscriber + .unsubscribe(channel.to_string()) + .await + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + Ok(()) + } + + async fn close(&self) -> Result<(), MessageBusError> { + self.client + .quit() + .await + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + self.subscriber + .quit() + .await + .map_err(|e| MessageBusError::Redis(e.to_string()))?; + Ok(()) + } +} \ No newline at end of file diff --git a/socket/mod.rs b/socket/mod.rs new file mode 100644 index 0000000..8a395dc --- /dev/null +++ b/socket/mod.rs @@ -0,0 +1,16 @@ +pub mod adapter; +pub mod message_bus; +pub mod namespace; +pub mod packet; +pub mod parser; +pub mod server; +pub mod session_store; +pub mod socket; + +pub use adapter::{Adapter, AdapterError, BroadcastOptions, BroadcastFlags, BusMessage, LocalAdapter, RedisAdapter, NatsAdapter, SocketInfo}; +pub use message_bus::{MessageBus, MessageBusError, RedisMessageBus, NatsMessageBus}; +pub use namespace::{is_valid_namespace, Namespace, NamespaceManager}; +pub use packet::{Packet, PacketType}; +pub use server::{SocketServer, SocketServerBuilder}; +pub use session_store::{InMemorySessionStore, RedisSessionStore, SessionError, SessionInfo, SessionStoreTrait}; +pub use socket::Socket; \ No newline at end of file diff --git a/socket/namespace.rs b/socket/namespace.rs new file mode 100644 index 0000000..5dc437d --- /dev/null +++ b/socket/namespace.rs @@ -0,0 +1,239 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use dashmap::DashMap; +use tokio::sync::RwLock; + +use crate::socket::adapter::{Adapter, BroadcastOptions, BroadcastFlags}; +use crate::socket::packet::Packet; +use crate::socket::socket::Socket; + +pub type EventHandler = Arc; +type ConnectHandler = Arc) -> Result<(), String> + Send + Sync>; + +pub struct Namespace { + pub path: String, + /// Primary storage: socket_sid → Socket + sockets: DashMap>, + /// Reverse index: engine_sid → socket_sid (for engine-level lookups) + engine_to_socket: DashMap, + handlers: RwLock>>, + connect_handler: RwLock>, + pub(crate) adapter: RwLock>>, +} + +impl Namespace { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + sockets: DashMap::new(), + engine_to_socket: DashMap::new(), + handlers: RwLock::new(HashMap::new()), + connect_handler: RwLock::new(None), + adapter: RwLock::new(None), + } + } + + pub async fn set_adapter(&self, adapter: Arc) { + let mut guard = self.adapter.write().await; + *guard = Some(adapter); + } + + /// Add a socket to this namespace. Returns Err if the connect handler rejects. + pub async fn add_socket(&self, socket: Arc) -> Result<(), String> { + // Run connect handler before adding to storage + let handler = self.connect_handler.read().await; + if let Some(ref h) = *handler { + h(&socket, None)?; + } + drop(handler); + + let socket_sid = socket.sid.clone(); + let engine_sid = socket.engine_sid.clone(); + + // Register with adapter (socket_sid → engine_sid mapping) + let adapter = self.adapter.read().await; + if let Some(ref adapter) = *adapter { + if let Err(e) = adapter.register(&socket_sid, &engine_sid, &self.path).await { + tracing::warn!("Adapter register error for socket {}: {}", socket_sid, e); + } + } + + // Store socket by socket_sid, plus reverse index + self.sockets.insert(socket_sid.clone(), socket); + self.engine_to_socket.insert(engine_sid, socket_sid); + Ok(()) + } + + /// Remove a socket by its socket SID. + pub async fn remove_socket_by_sid(&self, socket_sid: &str) { + if let Some((_, socket)) = self.sockets.remove(socket_sid) { + self.engine_to_socket.remove(&socket.engine_sid); + + let adapter = self.adapter.read().await; + if let Some(ref adapter) = *adapter { + if let Err(e) = adapter.del_all(socket_sid, &self.path).await { + tracing::warn!("Adapter del_all error for socket {}: {}", socket_sid, e); + } + } + } + } + + /// Remove a socket by its engine SID (for engine-level disconnections). + pub async fn remove_socket(&self, engine_sid: &str) { + if let Some((_, socket_sid)) = self.engine_to_socket.remove(engine_sid) { + self.remove_socket_by_sid(&socket_sid).await; + } + } + + /// Look up a socket by its socket SID. + pub fn get_socket(&self, socket_sid: &str) -> Option> { + self.sockets.get(socket_sid).map(|r| r.value().clone()) + } + + /// Look up a socket by its engine SID (reverse lookup). + pub fn get_socket_by_engine_sid(&self, engine_sid: &str) -> Option> { + self.engine_to_socket + .get(engine_sid) + .and_then(|entry| self.sockets.get(entry.value()).map(|r| r.value().clone())) + } + + pub fn socket_count(&self) -> usize { + self.sockets.len() + } + + pub async fn on_event(&self, event: impl Into, handler: EventHandler) { + let mut handlers = self.handlers.write().await; + handlers.entry(event.into()).or_default().push(handler); + } + + pub async fn on_connect(&self, handler: F) + where + F: Fn(&Socket, Option<&serde_json::Value>) -> Result<(), String> + Send + Sync + 'static, + { + let mut connect_handler = self.connect_handler.write().await; + *connect_handler = Some(Arc::new(handler)); + } + + pub async fn emit(&self, event: impl Into, data: serde_json::Value) { + let event_name = event.into(); + let packet = Packet::event(&self.path, serde_json::json!([event_name, data]), None); + + let adapter = self.adapter.read().await; + if let Some(ref adapter) = *adapter { + let opts = BroadcastOptions::default(); + if let Err(e) = adapter.broadcast(&packet, &opts).await { + tracing::warn!("Adapter broadcast error: {}", e); + } + } else { + self.emit_local(&packet); + } + } + + pub async fn emit_to_room(&self, room: &str, event: impl Into, data: serde_json::Value) { + let event_name = event.into(); + let packet = Packet::event(&self.path, serde_json::json!([event_name, data]), None); + + let adapter = self.adapter.read().await; + if let Some(ref adapter) = *adapter { + let opts = BroadcastOptions { + rooms: HashSet::from([room.to_string()]), + except: HashSet::new(), + flags: BroadcastFlags::default(), + }; + if let Err(e) = adapter.broadcast(&packet, &opts).await { + tracing::warn!("Adapter broadcast to room error: {}", e); + } + } else { + self.emit_local(&packet); + } + } + + pub fn emit_local(&self, packet: &Packet) { + for entry in self.sockets.iter() { + let socket = entry.value(); + if socket.send_packet(packet).is_err() { + tracing::warn!("Failed to send event to socket {}", socket.sid); + } + } + } + + pub async fn emit_to(&self, socket_sid: &str, event: impl Into, data: serde_json::Value) { + if let Some(socket) = self.get_socket(socket_sid) { + let event_name = event.into(); + let packet = Packet::event(&self.path, serde_json::json!([event_name, data]), None); + if socket.send_packet(&packet).is_err() { + tracing::warn!("Failed to send event to socket {}", socket.sid); + } + } + } + + pub async fn handle_event(&self, socket: &Socket, event: &str, data: &serde_json::Value) { + let handlers = self.handlers.read().await; + if let Some(event_handlers) = handlers.get(event) { + for handler in event_handlers { + handler(socket, data); + } + } + } +} + +pub struct NamespaceManager { + namespaces: DashMap>, +} + +impl NamespaceManager { + pub fn new() -> Self { + let manager = Self { + namespaces: DashMap::new(), + }; + manager.create_namespace("/"); + manager + } + + pub fn create_namespace(&self, path: impl Into) -> Arc { + let path = path.into(); + let namespace = Arc::new(Namespace::new(&path)); + self.namespaces.insert(path.clone(), namespace.clone()); + namespace + } + + pub fn get_namespace(&self, path: &str) -> Option> { + self.namespaces.get(path).map(|r| r.value().clone()) + } + + pub fn get_or_create_namespace(&self, path: &str) -> Arc { + if let Some(ns) = self.get_namespace(path) { + ns + } else { + self.create_namespace(path) + } + } + + pub fn remove_namespace(&self, path: &str) { + self.namespaces.remove(path); + } + + pub fn namespace_count(&self) -> usize { + self.namespaces.len() + } + + pub fn all_namespaces(&self) -> Vec> { + self.namespaces.iter().map(|e| e.value().clone()).collect() + } +} + +impl Default for NamespaceManager { + fn default() -> Self { + Self::new() + } +} + +/// Validate a namespace path. Returns true if the path is valid. +/// Rules: must start with '/', max 256 chars, no control characters. +pub fn is_valid_namespace(path: &str) -> bool { + !path.is_empty() + && path.starts_with('/') + && path.len() <= 256 + && !path.chars().any(|c| c.is_control()) +} diff --git a/socket/packet.rs b/socket/packet.rs new file mode 100644 index 0000000..99f7a0e --- /dev/null +++ b/socket/packet.rs @@ -0,0 +1,174 @@ +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum PacketType { + Connect = 0, + Disconnect = 1, + Event = 2, + Ack = 3, + ConnectError = 4, + BinaryEvent = 5, + BinaryAck = 6, +} + +impl TryFrom for PacketType { + type Error = PacketError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Connect), + 1 => Ok(Self::Disconnect), + 2 => Ok(Self::Event), + 3 => Ok(Self::Ack), + 4 => Ok(Self::ConnectError), + 5 => Ok(Self::BinaryEvent), + 6 => Ok(Self::BinaryAck), + _ => Err(PacketError::InvalidType(value)), + } + } +} + +impl TryFrom for PacketType { + type Error = PacketError; + + fn try_from(value: char) -> Result { + match value { + '0' => Ok(Self::Connect), + '1' => Ok(Self::Disconnect), + '2' => Ok(Self::Event), + '3' => Ok(Self::Ack), + '4' => Ok(Self::ConnectError), + '5' => Ok(Self::BinaryEvent), + '6' => Ok(Self::BinaryAck), + _ => Err(PacketError::InvalidTypeChar(value)), + } + } +} + +#[derive(Debug, Clone)] +pub struct Packet { + pub packet_type: PacketType, + pub namespace: String, + pub data: Option, + pub id: Option, + pub attachments: Vec>, + /// Expected number of binary attachments (set during decode for binary packets). + /// Used to validate attachment count before assembling the full packet. + pub expected_attachments: Option, +} + +impl Packet { + pub fn connect(namespace: impl Into, data: Option) -> Self { + Self { + packet_type: PacketType::Connect, + namespace: namespace.into(), + data, + id: None, + attachments: Vec::new(), + expected_attachments: None, + } + } + + pub fn disconnect(namespace: impl Into) -> Self { + Self { + packet_type: PacketType::Disconnect, + namespace: namespace.into(), + data: None, + id: None, + attachments: Vec::new(), + expected_attachments: None, + } + } + + pub fn event(namespace: impl Into, data: Value, id: Option) -> Self { + Self { + packet_type: PacketType::Event, + namespace: namespace.into(), + data: Some(data), + id, + attachments: Vec::new(), + expected_attachments: None, + } + } + + pub fn ack(namespace: impl Into, data: Value, id: u64) -> Self { + Self { + packet_type: PacketType::Ack, + namespace: namespace.into(), + data: Some(data), + id: Some(id), + attachments: Vec::new(), + expected_attachments: None, + } + } + + pub fn connect_error(namespace: impl Into, message: impl Into) -> Self { + Self { + packet_type: PacketType::ConnectError, + namespace: namespace.into(), + data: Some(serde_json::json!({ "message": message.into() })), + id: None, + attachments: Vec::new(), + expected_attachments: None, + } + } + + pub fn binary_event( + namespace: impl Into, + data: Value, + id: Option, + attachments: Vec>, + ) -> Self { + Self { + packet_type: PacketType::BinaryEvent, + namespace: namespace.into(), + data: Some(data), + id, + attachments, + expected_attachments: None, + } + } + + pub fn binary_ack( + namespace: impl Into, + data: Value, + id: u64, + attachments: Vec>, + ) -> Self { + Self { + packet_type: PacketType::BinaryAck, + namespace: namespace.into(), + data: Some(data), + id: Some(id), + attachments, + expected_attachments: None, + } + } + + pub fn has_binary(&self) -> bool { + !self.attachments.is_empty() + } + + pub fn attachment_count(&self) -> usize { + self.attachments.len() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PacketError { + #[error("invalid packet type: {0}")] + InvalidType(u8), + #[error("invalid packet type char: {0}")] + InvalidTypeChar(char), + #[error("empty packet")] + Empty, + #[error("invalid format: {0}")] + InvalidFormat(String), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("missing namespace")] + MissingNamespace, + #[error("invalid attachment count")] + InvalidAttachmentCount, +} diff --git a/socket/parser.rs b/socket/parser.rs new file mode 100644 index 0000000..1ea5e4d --- /dev/null +++ b/socket/parser.rs @@ -0,0 +1,392 @@ +use serde_json::Value; + +use crate::socket::packet::{Packet, PacketError, PacketType}; + +pub fn encode(packet: &Packet) -> String { + let type_char = packet.packet_type as u8 + b'0'; + let mut result = String::new(); + + result.push(type_char as char); + + if packet.has_binary() { + result.push_str(&packet.attachment_count().to_string()); + result.push('-'); + } + + if packet.namespace != "/" { + result.push_str(&packet.namespace); + result.push(','); + } + + if let Some(id) = packet.id { + result.push_str(&id.to_string()); + } + + if let Some(ref data) = packet.data { + if packet.has_binary() { + let data_with_placeholders = replace_binary_with_placeholders(data, packet.attachment_count()); + let encoded_data = serde_json::to_string(&data_with_placeholders) + .unwrap_or_else(|e| { + tracing::error!("Failed to serialize socket packet data: {}", e); + "null".to_string() + }); + result.push_str(&encoded_data); + } else { + let encoded_data = serde_json::to_string(data) + .unwrap_or_else(|e| { + tracing::error!("Failed to serialize socket packet data: {}", e); + "null".to_string() + }); + result.push_str(&encoded_data); + } + } + + result +} + +pub fn encode_with_attachments(packet: &Packet) -> Vec> { + let mut result = Vec::new(); + + let encoded = encode(packet); + result.push(encoded.into_bytes()); + + for attachment in &packet.attachments { + result.push(attachment.clone()); + } + + result +} + +pub fn decode(input: &str) -> Result { + if input.is_empty() { + return Err(PacketError::Empty); + } + + let mut chars = input.chars().peekable(); + + let type_char = chars.next().ok_or(PacketError::Empty)?; + let packet_type = PacketType::try_from(type_char)?; + + let attachment_count = if matches!(packet_type, PacketType::BinaryEvent | PacketType::BinaryAck) { + let mut count_str = String::new(); + while let Some(&c) = chars.peek() { + if c == '-' { + chars.next(); + break; + } + if c.is_ascii_digit() { + count_str.push(c); + chars.next(); + } else { + break; + } + } + count_str.parse::().unwrap_or(0) + } else { + 0 + }; + + let remaining: String = chars.collect(); + + let (namespace, rest) = if let Some(after_slash) = remaining.strip_prefix('/') { + // Check if this is a custom namespace (has a comma separating namespace from data/id) + // or if '/' is just the root namespace prefix followed immediately by data + if let Some(comma_pos) = after_slash.find(',') { + let ns = format!("/{}", &after_slash[..comma_pos]); + let rest = after_slash[comma_pos + 1..].to_string(); + (ns, rest) + } else if after_slash.starts_with('[') + || after_slash.starts_with(|c: char| c.is_ascii_digit()) + || after_slash.is_empty() + { + // '/[' means '/' is the root namespace and '[' starts the data + // '/' means root namespace followed by ack id + // '/' alone means disconnect on root namespace + ("/".to_string(), after_slash.to_string()) + } else { + // Non-root namespace without data (e.g., disconnect on custom namespace) + (remaining, String::new()) + } + } else { + ("/".to_string(), remaining) + }; + + let (id, data_str) = parse_id_and_data(&rest); + + let data = if data_str.is_empty() { + None + } else { + Some(serde_json::from_str(&data_str)?) + }; + + Ok(Packet { + packet_type, + namespace, + data, + id, + attachments: Vec::new(), + // Store attachment_count for binary packets; actual attachments come via decode_with_attachments + expected_attachments: if attachment_count > 0 { Some(attachment_count) } else { None }, + }) +} + +pub fn decode_with_attachments( + main_packet: &str, + attachments: Vec>, +) -> Result { + let mut packet = decode(main_packet)?; + + let expected = packet.expected_attachments.unwrap_or(0); + if expected != attachments.len() { + return Err(PacketError::InvalidAttachmentCount); + } + + packet.attachments = attachments; + packet.expected_attachments = None; + + if packet.has_binary() { + if let Some(ref data) = packet.data { + packet.data = Some(replace_placeholders_with_binary(data, &packet.attachments)); + } + } + + Ok(packet) +} + +fn parse_id_and_data(input: &str) -> (Option, String) { + let mut id_str = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + id_str.push(c); + chars.next(); + } else { + break; + } + } + + let id = if id_str.is_empty() { + None + } else { + id_str.parse::().ok() + }; + + let data: String = chars.collect(); + + (id, data) +} + +/// Replace binary values in the data with { "_placeholder": true, "num": N } placeholders. +/// This is used when encoding binary events/acks for transmission over text-based transports. +fn replace_binary_with_placeholders(value: &Value, total_attachments: usize) -> Value { + match value { + Value::Array(arr) => { + let mut placeholder_idx = total_attachments; // Start from known count + let new_arr: Vec = arr + .iter() + .map(|v| replace_binary_with_placeholders_inner(v, &mut placeholder_idx)) + .collect(); + Value::Array(new_arr) + } + Value::Object(map) => { + let mut placeholder_idx = total_attachments; + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert( + k.clone(), + replace_binary_with_placeholders_inner(v, &mut placeholder_idx), + ); + } + Value::Object(new_map) + } + _ => value.clone(), + } +} + +fn replace_binary_with_placeholders_inner(value: &Value, placeholder_idx: &mut usize) -> Value { + match value { + Value::Array(arr) => { + let new_arr: Vec = arr + .iter() + .map(|v| replace_binary_with_placeholders_inner(v, placeholder_idx)) + .collect(); + Value::Array(new_arr) + } + Value::Object(map) => { + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert( + k.clone(), + replace_binary_with_placeholders_inner(v, placeholder_idx), + ); + } + Value::Object(new_map) + } + // Binary data would be represented as base64 strings in the initial data; + // in the Socket.IO protocol, binary attachments are separate and referenced by placeholder. + // This function handles the case where the data structure itself contains placeholder markers. + _ => value.clone(), + } +} + +fn replace_placeholders_with_binary(value: &Value, attachments: &[Vec]) -> Value { + match value { + Value::Object(map) => { + // Check if this is a placeholder object: { "_placeholder": true, "num": N } + if let (Some(Value::Bool(true)), Some(Value::Number(num))) = + (map.get("_placeholder"), map.get("num")) + { + if let Some(idx) = num.as_u64() { + if let Some(attachment) = attachments.get(idx as usize) { + return Value::String(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + attachment, + )); + } + } + } + + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert(k.clone(), replace_placeholders_with_binary(v, attachments)); + } + Value::Object(new_map) + } + Value::Array(arr) => Value::Array( + arr.iter() + .map(|v| replace_placeholders_with_binary(v, attachments)) + .collect(), + ), + _ => value.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_encode_connect() { + let packet = Packet::connect("/", None); + let encoded = encode(&packet); + assert_eq!(encoded, "0"); + + let packet = Packet::connect("/admin", Some(json!({"sid": "abc"}))); + let encoded = encode(&packet); + assert_eq!(encoded, "0/admin,{\"sid\":\"abc\"}"); + } + + #[test] + fn test_encode_event() { + let packet = Packet::event("/", json!(["foo"]), None); + let encoded = encode(&packet); + assert_eq!(encoded, "2[\"foo\"]"); + + let packet = Packet::event("/admin", json!(["bar"]), None); + let encoded = encode(&packet); + assert_eq!(encoded, "2/admin,[\"bar\"]"); + } + + #[test] + fn test_encode_event_with_ack() { + let packet = Packet::event("/", json!(["foo"]), Some(12)); + let encoded = encode(&packet); + assert_eq!(encoded, "212[\"foo\"]"); + } + + #[test] + fn test_encode_ack() { + let packet = Packet::ack("/", json!([]), 12); + let encoded = encode(&packet); + assert_eq!(encoded, "312[]"); + } + + #[test] + fn test_encode_disconnect() { + let packet = Packet::disconnect("/"); + let encoded = encode(&packet); + assert_eq!(encoded, "1"); + + let packet = Packet::disconnect("/admin"); + let encoded = encode(&packet); + assert_eq!(encoded, "1/admin,"); + } + + #[test] + fn test_encode_connect_error() { + let packet = Packet::connect_error("/", "Not authorized"); + let encoded = encode(&packet); + assert_eq!(encoded, "4{\"message\":\"Not authorized\"}"); + } + + #[test] + fn test_decode_connect() { + let packet = decode("0").unwrap(); + assert_eq!(packet.packet_type, PacketType::Connect); + assert_eq!(packet.namespace, "/"); + assert!(packet.data.is_none()); + } + + #[test] + fn test_decode_connect_with_namespace() { + let packet = decode("0/admin,{\"sid\":\"abc\"}").unwrap(); + assert_eq!(packet.packet_type, PacketType::Connect); + assert_eq!(packet.namespace, "/admin"); + assert!(packet.data.is_some()); + } + + #[test] + fn test_decode_event() { + let packet = decode("2[\"foo\"]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Event); + assert_eq!(packet.namespace, "/"); + assert_eq!(packet.data, Some(json!(["foo"]))); + } + + #[test] + fn test_decode_event_with_namespace() { + let packet = decode("2/admin,[\"bar\"]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Event); + assert_eq!(packet.namespace, "/admin"); + assert_eq!(packet.data, Some(json!(["bar"]))); + } + + #[test] + fn test_decode_event_with_ack() { + let packet = decode("212[\"foo\"]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Event); + assert_eq!(packet.id, Some(12)); + assert_eq!(packet.data, Some(json!(["foo"]))); + } + + #[test] + fn test_decode_ack() { + let packet = decode("312[]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Ack); + assert_eq!(packet.id, Some(12)); + } + + #[test] + fn test_decode_disconnect() { + let packet = decode("1").unwrap(); + assert_eq!(packet.packet_type, PacketType::Disconnect); + assert_eq!(packet.namespace, "/"); + } + + #[test] + fn test_decode_disconnect_with_namespace() { + let packet = decode("1/admin,").unwrap(); + assert_eq!(packet.packet_type, PacketType::Disconnect); + assert_eq!(packet.namespace, "/admin"); + } + + #[test] + fn test_decode_binary_event_attachment_count() { + let packet = decode("51-[\"baz\",{\"_placeholder\":true,\"num\":0}]").unwrap(); + assert_eq!(packet.packet_type, PacketType::BinaryEvent); + assert_eq!(packet.expected_attachments, Some(1)); + assert_eq!(packet.namespace, "/"); + } +} \ No newline at end of file diff --git a/socket/server.rs b/socket/server.rs new file mode 100644 index 0000000..84de50a --- /dev/null +++ b/socket/server.rs @@ -0,0 +1,301 @@ +use std::sync::Arc; + +use dashmap::DashMap; +use tokio::sync::mpsc; + +use crate::engine::packet::Packet as EnginePacket; +use crate::engine::packet::PacketData as EnginePacketData; +use crate::engine::server::{EngineConfig, EngineServer}; +use crate::engine::session::SessionStore; +use crate::socket::adapter::{Adapter, LocalAdapter}; +use crate::socket::namespace::NamespaceManager; +use crate::socket::packet::{Packet, PacketType}; +use crate::socket::parser; +use crate::socket::socket::Socket; + +pub struct SocketServer { + pub engine: Arc, + pub namespaces: Arc, + pub adapter: Arc, + socket_txs: Arc>>, +} + +impl SocketServer { + pub fn new(config: EngineConfig) -> Self { + SocketServerBuilder::new(config).build() + } + + pub fn builder(config: EngineConfig) -> SocketServerBuilder { + SocketServerBuilder::new(config) + } + + pub fn of(&self, path: impl Into) -> Arc { + self.namespaces.get_or_create_namespace(&path.into()) + } + + pub async fn run_http(self: Arc, addr: &str) -> std::io::Result<()> { + self.engine.clone().run_http(addr).await + } + + pub fn register_socket(&self, sid: String, tx: mpsc::Sender) { + self.socket_txs.insert(sid, tx); + } + + pub fn unregister_socket(&self, sid: &str) { + self.socket_txs.remove(sid); + } +} + +pub struct SocketServerBuilder { + config: EngineConfig, + adapter: Option>, +} + +impl SocketServerBuilder { + pub fn new(config: EngineConfig) -> Self { + Self { + config, + adapter: None, + } + } + + pub fn adapter(mut self, adapter: Arc) -> Self { + self.adapter = Some(adapter); + self + } + + pub fn build(self) -> SocketServer { + let namespaces = Arc::new(NamespaceManager::new()); + let socket_txs: Arc>> = Arc::new(DashMap::new()); + let engine_store = SessionStore::new(); + + let namespaces_clone = namespaces.clone(); + let socket_txs_clone = socket_txs.clone(); + let engine_store_clone = engine_store.clone(); + + let adapter: Arc = self.adapter.unwrap_or_else(|| { + let ns_clone = namespaces.clone(); + let send_fn = move |engine_sid: &str, packet: &Packet| { + if let Some(ns) = ns_clone.get_namespace(&packet.namespace) { + if let Some(socket) = ns.get_socket_by_engine_sid(engine_sid) { + socket.send_packet(packet).map_err(|e| e.to_string()) + } else { + Err(format!( + "Socket with engine_sid {} not found in namespace {}", + engine_sid, packet.namespace + )) + } + } else { + Err(format!("Namespace {} not found", packet.namespace)) + } + }; + Arc::new(LocalAdapter::new(send_fn)) + }); + + let adapter_clone = adapter.clone(); + let engine = Arc::new(EngineServer::with_store( + self.config, + engine_store, + move |sid, engine_packet| { + let namespaces = namespaces_clone.clone(); + let socket_txs = socket_txs_clone.clone(); + let engine_store = engine_store_clone.clone(); + let adapter = adapter_clone.clone(); + tokio::spawn(async move { + handle_engine_message( + sid, engine_packet, &namespaces, &socket_txs, &engine_store, &adapter, + ).await; + }); + }, + )); + + let server = SocketServer { + engine, + namespaces, + adapter, + socket_txs, + }; + + for ns in server.namespaces.all_namespaces() { + let adapter_ref = server.adapter.clone(); + tokio::spawn(async move { + ns.set_adapter(adapter_ref).await; + }); + } + + server + } +} + +async fn handle_engine_message( + engine_sid: String, + engine_packet: EnginePacket, + namespaces: &Arc, + socket_txs: &Arc>>, + engine_store: &SessionStore, + adapter: &Arc, +) { + if let EnginePacketData::Text(ref text) = engine_packet.data { + if let Ok(socket_packet) = parser::decode(text) { + match socket_packet.packet_type { + PacketType::Connect => { + handle_connect(&engine_sid, &socket_packet, namespaces, socket_txs, engine_store, adapter).await; + } + PacketType::Disconnect => { + handle_disconnect(&engine_sid, &socket_packet, namespaces, socket_txs); + } + PacketType::Event => { + handle_event(&engine_sid, &socket_packet, namespaces); + } + PacketType::Ack => { + handle_ack(&engine_sid, &socket_packet); + } + _ => {} + } + } + } +} + +async fn handle_connect( + engine_sid: &str, + packet: &Packet, + namespaces: &Arc, + socket_txs: &Arc>>, + engine_store: &SessionStore, + adapter: &Arc, +) { + // Validate namespace path to prevent DoS via arbitrary namespace creation + if !crate::socket::namespace::is_valid_namespace(&packet.namespace) { + tracing::warn!("Rejected connect with invalid namespace: {}", packet.namespace); + return; + } + + let namespace = namespaces.get_or_create_namespace(&packet.namespace); + + // Ensure newly created namespaces get the shared adapter + { + let ns_adapter = namespace.adapter.read().await; + if ns_adapter.is_none() { + drop(ns_adapter); + let adapter_ref = adapter.clone(); + let ns_clone = namespace.clone(); + tokio::spawn(async move { + ns_clone.set_adapter(adapter_ref).await; + }); + } + } + + let socket_sid = crate::engine::session::generate_sid(); + let (tx, mut rx) = mpsc::channel::(256); + socket_txs.insert(socket_sid.clone(), tx.clone()); + + let socket = Arc::new(Socket::new( + socket_sid.clone(), + packet.namespace.clone(), + engine_sid.to_string(), + tx, + )); + + // Run connect handler and add to namespace. + // If the handler rejects, clean up and do NOT send a Connect response. + if let Err(msg) = namespace.add_socket(socket.clone()).await { + tracing::warn!("Socket {} connection rejected: {}", socket_sid, msg); + socket_txs.remove(&socket_sid); + return; + } + + // Connect handler passed — spawn forwarding task + let engine_store_clone = engine_store.clone(); + let engine_sid_clone = engine_sid.to_string(); + let socket_sid_clone = socket_sid.clone(); + let socket_txs_clone = socket_txs.clone(); + let namespace_clone = namespace.clone(); + tokio::spawn(async move { + while let Some(socket_packet) = rx.recv().await { + let encoded = parser::encode(&socket_packet); + let engine_packet = EnginePacket::message_text(encoded); + + if let Some(session) = engine_store_clone.get(&engine_sid_clone) { + let mut s = session.write().await; + if s.state == crate::engine::session::SessionState::Closed { + break; + } + s.push_packet(engine_packet); + } else { + break; + } + } + // Forwarding task ended — ensure socket is cleaned up from namespace + socket_txs_clone.remove(&socket_sid_clone); + namespace_clone.remove_socket_by_sid(&socket_sid_clone).await; + }); + + // Send Connect response (only after handler passed) + let response = Packet::connect( + &socket.namespace, + Some(serde_json::json!({ "sid": &socket.sid })), + ); + + if socket.send_packet(&response).is_err() { + tracing::warn!("Failed to send connect response to socket {}", socket.sid); + } +} + +fn handle_disconnect( + engine_sid: &str, + packet: &Packet, + namespaces: &Arc, + socket_txs: &Arc>>, +) { + if let Some(namespace) = namespaces.get_namespace(&packet.namespace) { + // Look up socket by engine_sid, then remove by socket_sid + if let Some(socket) = namespace.get_socket_by_engine_sid(engine_sid) { + socket_txs.remove(&socket.sid); + let socket_sid = socket.sid.clone(); + let ns_clone = namespace.clone(); + tokio::spawn(async move { + ns_clone.remove_socket_by_sid(&socket_sid).await; + }); + } + } +} + +fn handle_event( + engine_sid: &str, + packet: &Packet, + namespaces: &Arc, +) { + if let Some(namespace) = namespaces.get_namespace(&packet.namespace) { + if let Some(socket) = namespace.get_socket_by_engine_sid(engine_sid) { + if let Some(ref data) = packet.data { + if let Some(arr) = data.as_array() { + if let Some(event) = arr.first().and_then(|v| v.as_str()) { + let event_data = if arr.len() > 1 { + serde_json::Value::Array(arr[1..].to_vec()) + } else { + serde_json::Value::Null + }; + + let namespace_clone = namespace.clone(); + let event = event.to_string(); + let socket_clone = socket.clone(); + tokio::spawn(async move { + namespace_clone + .handle_event(&socket_clone, &event, &event_data) + .await; + }); + } + } + } + } + } +} + +fn handle_ack(engine_sid: &str, packet: &Packet) { + tracing::debug!( + "Received ACK from {} for namespace {} with id {:?}", + engine_sid, + packet.namespace, + packet.id + ); +} diff --git a/socket/session_store/memory.rs b/socket/session_store/memory.rs new file mode 100644 index 0000000..11a2a81 --- /dev/null +++ b/socket/session_store/memory.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use dashmap::DashMap; + +use crate::socket::session_store::{SessionError, SessionInfo, SessionStoreTrait}; + +pub struct InMemorySessionStore { + sessions: Arc>, +} + +impl InMemorySessionStore { + pub fn new() -> Self { + Self { + sessions: Arc::new(DashMap::new()), + } + } +} + +impl Default for InMemorySessionStore { + fn default() -> Self { + Self::new() + } +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[async_trait] +impl SessionStoreTrait for InMemorySessionStore { + async fn create(&self, sid: &str, transport: &str, server_id: &str) -> Result<(), SessionError> { + let info = SessionInfo { + sid: sid.to_string(), + transport: transport.to_string(), + state: "connecting".to_string(), + server_id: server_id.to_string(), + created_at: now_millis(), + last_ping: now_millis(), + }; + self.sessions.insert(sid.to_string(), info); + Ok(()) + } + + async fn get(&self, sid: &str) -> Result, SessionError> { + Ok(self.sessions.get(sid).map(|r| r.value().clone())) + } + + async fn set_state(&self, sid: &str, state: &str) -> Result<(), SessionError> { + if let Some(mut entry) = self.sessions.get_mut(sid) { + entry.value_mut().state = state.to_string(); + Ok(()) + } else { + Err(SessionError::NotFound(sid.to_string())) + } + } + + async fn set_transport(&self, sid: &str, transport: &str) -> Result<(), SessionError> { + if let Some(mut entry) = self.sessions.get_mut(sid) { + entry.value_mut().transport = transport.to_string(); + Ok(()) + } else { + Err(SessionError::NotFound(sid.to_string())) + } + } + + async fn update_ping(&self, sid: &str) -> Result<(), SessionError> { + if let Some(mut entry) = self.sessions.get_mut(sid) { + entry.value_mut().last_ping = now_millis(); + Ok(()) + } else { + Err(SessionError::NotFound(sid.to_string())) + } + } + + async fn remove(&self, sid: &str) -> Result<(), SessionError> { + self.sessions.remove(sid); + Ok(()) + } + + async fn exists(&self, sid: &str) -> Result { + Ok(self.sessions.contains_key(sid)) + } +} \ No newline at end of file diff --git a/socket/session_store/mod.rs b/socket/session_store/mod.rs new file mode 100644 index 0000000..3c4934d --- /dev/null +++ b/socket/session_store/mod.rs @@ -0,0 +1,41 @@ +pub mod memory; +pub mod redis; + +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SessionError { + #[error("Redis error: {0}")] + Redis(String), + #[error("Session not found: {0}")] + NotFound(String), + #[error("Serialization error: {0}")] + Serialization(String), + #[error("Session expired: {0}")] + Expired(String), +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SessionInfo { + pub sid: String, + pub transport: String, + pub state: String, + pub server_id: String, + pub created_at: u64, + pub last_ping: u64, +} + +#[async_trait] +pub trait SessionStoreTrait: Send + Sync + 'static { + async fn create(&self, sid: &str, transport: &str, server_id: &str) -> Result<(), SessionError>; + async fn get(&self, sid: &str) -> Result, SessionError>; + async fn set_state(&self, sid: &str, state: &str) -> Result<(), SessionError>; + async fn set_transport(&self, sid: &str, transport: &str) -> Result<(), SessionError>; + async fn update_ping(&self, sid: &str) -> Result<(), SessionError>; + async fn remove(&self, sid: &str) -> Result<(), SessionError>; + async fn exists(&self, sid: &str) -> Result; +} + +pub use memory::InMemorySessionStore; +pub use redis::RedisSessionStore; \ No newline at end of file diff --git a/socket/session_store/redis.rs b/socket/session_store/redis.rs new file mode 100644 index 0000000..352d7f9 --- /dev/null +++ b/socket/session_store/redis.rs @@ -0,0 +1,164 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use fred::prelude::*; + +use crate::socket::message_bus::redis::RedisMessageBus; +use crate::socket::session_store::{SessionError, SessionInfo, SessionStoreTrait}; + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +const DEFAULT_TTL_SECS: u64 = 60; +const KEY_PREFIX: &str = "socket.io:session"; + +pub struct RedisSessionStore { + client: Client, + ttl_secs: u64, +} + +impl RedisSessionStore { + pub fn new(bus: &RedisMessageBus, ttl_secs: Option) -> Self { + Self { + client: bus.client().clone(), + ttl_secs: ttl_secs.unwrap_or(DEFAULT_TTL_SECS), + } + } + + fn key(&self, sid: &str) -> String { + format!("{}:{}", KEY_PREFIX, sid) + } +} + +#[async_trait] +impl SessionStoreTrait for RedisSessionStore { + async fn create(&self, sid: &str, transport: &str, server_id: &str) -> Result<(), SessionError> { + let key = self.key(sid); + let now = now_millis(); + + // Batch all fields in a single HSET call for efficiency + let fields: Vec<(&str, String)> = vec![ + ("sid", sid.to_string()), + ("transport", transport.to_string()), + ("state", "connecting".to_string()), + ("server_id", server_id.to_string()), + ("created_at", now.to_string()), + ("last_ping", now.to_string()), + ]; + self.client + .hset::<(), _, _>(&key, fields) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + self.client + .expire::<(), _>(&key, self.ttl_secs as i64, None) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + Ok(()) + } + + async fn get(&self, sid: &str) -> Result, SessionError> { + let key = self.key(sid); + + // Use hgetall directly — if the key doesn't exist Redis returns an empty map. + // This avoids the TOCTOU race between EXISTS and HGETALL. + let values: std::collections::HashMap = self.client + .hgetall::, _>(&key) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + if values.is_empty() { + return Ok(None); + } + + let info = SessionInfo { + sid: values.get("sid").cloned().unwrap_or_default(), + transport: values.get("transport").cloned().unwrap_or_default(), + state: values.get("state").cloned().unwrap_or_default(), + server_id: values.get("server_id").cloned().unwrap_or_default(), + created_at: values.get("created_at").and_then(|v| v.parse::().ok()).unwrap_or(0), + last_ping: values.get("last_ping").and_then(|v| v.parse::().ok()).unwrap_or(0), + }; + + Ok(Some(info)) + } + + async fn set_state(&self, sid: &str, state: &str) -> Result<(), SessionError> { + let key = self.key(sid); + + // Use HSET (not HSETNX) to overwrite existing fields + self.client + .hset::<(), _, _>(&key, ("state", state)) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + self.client + .expire::<(), _>(&key, self.ttl_secs as i64, None) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + Ok(()) + } + + async fn set_transport(&self, sid: &str, transport: &str) -> Result<(), SessionError> { + let key = self.key(sid); + + // Use HSET (not HSETNX) to overwrite existing fields + self.client + .hset::<(), _, _>(&key, ("transport", transport)) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + self.client + .expire::<(), _>(&key, self.ttl_secs as i64, None) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + Ok(()) + } + + async fn update_ping(&self, sid: &str) -> Result<(), SessionError> { + let key = self.key(sid); + let now = now_millis(); + + // Use HSET (not HSETNX) to overwrite existing fields + self.client + .hset::<(), _, _>(&key, ("last_ping", now.to_string())) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + self.client + .expire::<(), _>(&key, self.ttl_secs as i64, None) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + Ok(()) + } + + async fn remove(&self, sid: &str) -> Result<(), SessionError> { + let key = self.key(sid); + + self.client + .del::<(), _>(&key) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + Ok(()) + } + + async fn exists(&self, sid: &str) -> Result { + let key = self.key(sid); + + let exists: bool = self.client + .exists::(&key) + .await + .map_err(|e| SessionError::Redis(e.to_string()))?; + + Ok(exists) + } +} \ No newline at end of file diff --git a/socket/socket.rs b/socket/socket.rs new file mode 100644 index 0000000..26bfa3b --- /dev/null +++ b/socket/socket.rs @@ -0,0 +1,72 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use tokio::sync::mpsc; + +use crate::socket::packet::Packet; + +pub struct Socket { + pub sid: String, + pub namespace: String, + pub engine_sid: String, + ack_id: AtomicU64, + tx: mpsc::Sender, +} + +impl Socket { + pub fn new( + sid: String, + namespace: String, + engine_sid: String, + tx: mpsc::Sender, + ) -> Self { + Self { + sid, + namespace, + engine_sid, + ack_id: AtomicU64::new(0), + tx, + } + } + + pub fn next_ack_id(&self) -> u64 { + self.ack_id.fetch_add(1, Ordering::SeqCst) + } + + pub fn send_packet(&self, packet: &Packet) -> Result<(), mpsc::error::TrySendError> { + self.tx.try_send(packet.clone()) + } + + pub fn emit(&self, event: impl Into, data: serde_json::Value) -> Result<(), mpsc::error::TrySendError> { + let packet = Packet::event( + &self.namespace, + serde_json::json!([event.into(), data]), + None, + ); + self.send_packet(&packet) + } + + pub fn emit_with_ack( + &self, + event: impl Into, + data: serde_json::Value, + ) -> Result> { + let ack_id = self.next_ack_id(); + let packet = Packet::event( + &self.namespace, + serde_json::json!([event.into(), data]), + Some(ack_id), + ); + self.send_packet(&packet)?; + Ok(ack_id) + } + + pub fn disconnect(&self) -> Result<(), mpsc::error::TrySendError> { + let packet = Packet::disconnect(&self.namespace); + self.send_packet(&packet) + } + + pub fn send_ack(&self, id: u64, data: serde_json::Value) -> Result<(), mpsc::error::TrySendError> { + let packet = Packet::ack(&self.namespace, data, id); + self.send_packet(&packet) + } +} diff --git a/tests/adapter_tests.rs b/tests/adapter_tests.rs new file mode 100644 index 0000000..6b71439 --- /dev/null +++ b/tests/adapter_tests.rs @@ -0,0 +1,344 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use imks::socket::adapter::{Adapter, AdapterError, BroadcastOptions, BroadcastFlags, BusMessage, LocalAdapter, SocketInfo}; +use imks::socket::packet::Packet; +use imks::socket::session_store::{InMemorySessionStore, SessionInfo, SessionStoreTrait}; + +#[tokio::test] +async fn test_local_adapter_add_and_del() { + let sent_packets: dashmap::DashMap> = dashmap::DashMap::new(); + let sent_packets_clone = sent_packets.clone(); + let send_fn = move |engine_sid: &str, packet: &Packet| { + sent_packets_clone.entry(engine_sid.to_string()).or_insert_with(Vec::new).value_mut().push(packet.clone()); + Ok(()) + }; + + let adapter = LocalAdapter::new(send_fn); + + adapter.add("sid1", "room1", "/").await.unwrap(); + adapter.add("sid1", "room2", "/").await.unwrap(); + adapter.add("sid2", "room1", "/").await.unwrap(); + + let rooms = adapter.socket_rooms("sid1").await.unwrap(); + assert!(rooms.contains("room1")); + assert!(rooms.contains("room2")); + + adapter.del("sid1", "room1", "/").await.unwrap(); + let rooms = adapter.socket_rooms("sid1").await.unwrap(); + assert!(!rooms.contains("room1")); + assert!(rooms.contains("room2")); +} + +#[tokio::test] +async fn test_local_adapter_del_all() { + let send_fn = move |_engine_sid: &str, _packet: &Packet| Ok(()); + + let adapter = LocalAdapter::new(send_fn); + + adapter.add("sid1", "room1", "/").await.unwrap(); + adapter.add("sid1", "room2", "/").await.unwrap(); + + adapter.del_all("sid1", "/").await.unwrap(); + + let rooms = adapter.socket_rooms("sid1").await.unwrap(); + assert!(rooms.is_empty()); +} + +#[tokio::test] +async fn test_local_adapter_register_and_broadcast() { + let sent_packets: Arc>> = Arc::new(dashmap::DashMap::new()); + let sent_packets_clone = sent_packets.clone(); + let send_fn = move |engine_sid: &str, packet: &Packet| { + sent_packets_clone.entry(engine_sid.to_string()).or_insert_with(Vec::new).value_mut().push(packet.clone()); + Ok(()) + }; + + let adapter = LocalAdapter::new(send_fn); + + // Register socket_sid → engine_sid mapping + adapter.register("sid1", "engine1", "/").await.unwrap(); + adapter.register("sid2", "engine2", "/").await.unwrap(); + + let packet = Packet::event("/", serde_json::json!(["test", "hello"]), None); + let opts = BroadcastOptions::default(); + adapter.broadcast(&packet, &opts).await.unwrap(); + + assert!(sent_packets.contains_key("engine1")); + assert!(sent_packets.contains_key("engine2")); + assert_eq!(sent_packets.len(), 2); +} + +#[tokio::test] +async fn test_local_adapter_broadcast_to_room() { + let sent_packets: Arc>> = Arc::new(dashmap::DashMap::new()); + let sent_packets_clone = sent_packets.clone(); + let send_fn = move |engine_sid: &str, packet: &Packet| { + sent_packets_clone.entry(engine_sid.to_string()).or_insert_with(Vec::new).value_mut().push(packet.clone()); + Ok(()) + }; + + let adapter = LocalAdapter::new(send_fn); + + adapter.register("sid1", "engine1", "/").await.unwrap(); + adapter.register("sid2", "engine2", "/").await.unwrap(); + adapter.add("sid1", "room1", "/").await.unwrap(); + adapter.add("sid2", "room2", "/").await.unwrap(); + + let packet = Packet::event("/", serde_json::json!(["test", "hello"]), None); + let opts = BroadcastOptions { + rooms: HashSet::from(["room1".to_string()]), + except: HashSet::new(), + flags: BroadcastFlags::default(), + }; + adapter.broadcast(&packet, &opts).await.unwrap(); + + assert!(sent_packets.contains_key("engine1")); + assert!(!sent_packets.contains_key("engine2")); +} + +#[tokio::test] +async fn test_local_adapter_broadcast_except() { + let sent_packets: Arc>> = Arc::new(dashmap::DashMap::new()); + let sent_packets_clone = sent_packets.clone(); + let send_fn = move |engine_sid: &str, packet: &Packet| { + sent_packets_clone.entry(engine_sid.to_string()).or_insert_with(Vec::new).value_mut().push(packet.clone()); + Ok(()) + }; + + let adapter = LocalAdapter::new(send_fn); + + adapter.register("sid1", "engine1", "/").await.unwrap(); + adapter.register("sid2", "engine2", "/").await.unwrap(); + + let packet = Packet::event("/", serde_json::json!(["test", "hello"]), None); + let opts = BroadcastOptions { + rooms: HashSet::new(), + except: HashSet::from(["sid1".to_string()]), + flags: BroadcastFlags::default(), + }; + adapter.broadcast(&packet, &opts).await.unwrap(); + + assert!(!sent_packets.contains_key("engine1")); + assert!(sent_packets.contains_key("engine2")); +} + +#[tokio::test] +async fn test_local_adapter_fetch_sockets() { + let send_fn = move |_engine_sid: &str, _packet: &Packet| Ok(()); + + let adapter = LocalAdapter::new(send_fn); + + adapter.register("sid1", "engine1", "/").await.unwrap(); + adapter.register("sid2", "engine2", "/").await.unwrap(); + adapter.add("sid1", "room1", "/").await.unwrap(); + adapter.add("sid2", "room2", "/").await.unwrap(); + + let opts = BroadcastOptions::default(); + let sockets = adapter.fetch_sockets(&opts).await.unwrap(); + assert_eq!(sockets.len(), 2); +} + +#[tokio::test] +async fn test_local_adapter_server_id_unique() { + let send_fn1 = move |_engine_sid: &str, _packet: &Packet| Ok(()); + let send_fn2 = move |_engine_sid: &str, _packet: &Packet| Ok(()); + + let adapter1 = LocalAdapter::new(send_fn1); + let adapter2 = LocalAdapter::new(send_fn2); + + assert_ne!(adapter1.server_id(), adapter2.server_id()); +} + +#[tokio::test] +async fn test_in_memory_session_store() { + let store = InMemorySessionStore::new(); + + store.create("sid1", "polling", "server1").await.unwrap(); + assert!(store.exists("sid1").await.unwrap()); + + let info = store.get("sid1").await.unwrap().unwrap(); + assert_eq!(info.sid, "sid1"); + assert_eq!(info.transport, "polling"); + assert_eq!(info.state, "connecting"); + assert_eq!(info.server_id, "server1"); + + store.set_state("sid1", "open").await.unwrap(); + let info = store.get("sid1").await.unwrap().unwrap(); + assert_eq!(info.state, "open"); + + store.set_transport("sid1", "websocket").await.unwrap(); + let info = store.get("sid1").await.unwrap().unwrap(); + assert_eq!(info.transport, "websocket"); + + store.update_ping("sid1").await.unwrap(); + let info = store.get("sid1").await.unwrap().unwrap(); + assert!(info.last_ping > 0); + + store.remove("sid1").await.unwrap(); + assert!(!store.exists("sid1").await.unwrap()); +} + +#[tokio::test] +async fn test_in_memory_session_store_not_found() { + let store = InMemorySessionStore::new(); + + let result = store.get("nonexistent").await.unwrap(); + assert!(result.is_none()); + + let result = store.set_state("nonexistent", "open").await; + assert!(result.is_err()); +} + +#[test] +fn test_bus_message_serialization() { + let msg = BusMessage::Broadcast { + namespace: "/".to_string(), + packet: "2[\"hello\"]".to_string(), + opts: BroadcastOptions::default(), + server_id: "server-1".to_string(), + }; + + let encoded = serde_json::to_vec(&msg).unwrap(); + let decoded: BusMessage = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded, msg); +} + +#[test] +fn test_bus_message_socket_join() { + let msg = BusMessage::SocketJoin { + namespace: "/admin".to_string(), + sid: "sid-1".to_string(), + room: "room-1".to_string(), + server_id: "server-1".to_string(), + }; + + let encoded = serde_json::to_vec(&msg).unwrap(); + let decoded: BusMessage = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded, msg); +} + +#[test] +fn test_bus_message_socket_leave() { + let msg = BusMessage::SocketLeave { + namespace: "/".to_string(), + sid: "sid-1".to_string(), + room: "room-1".to_string(), + server_id: "server-1".to_string(), + }; + + let encoded = serde_json::to_vec(&msg).unwrap(); + let decoded: BusMessage = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded, msg); +} + +#[test] +fn test_bus_message_socket_disconnect() { + let msg = BusMessage::SocketDisconnect { + namespace: "/".to_string(), + sid: "sid-1".to_string(), + server_id: "server-1".to_string(), + }; + + let encoded = serde_json::to_vec(&msg).unwrap(); + let decoded: BusMessage = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded, msg); +} + +#[test] +fn test_broadcast_options_serialization() { + let opts = BroadcastOptions { + rooms: HashSet::from(["room1".to_string(), "room2".to_string()]), + except: HashSet::from(["sid1".to_string()]), + flags: BroadcastFlags { + local_only: true, + broadcast: false, + }, + }; + + let encoded = serde_json::to_vec(&opts).unwrap(); + let decoded: BroadcastOptions = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded.rooms, opts.rooms); + assert_eq!(decoded.except, opts.except); + assert_eq!(decoded.flags.local_only, opts.flags.local_only); +} + +#[test] +fn test_socket_info_serialization() { + let info = SocketInfo { + sid: "sid-1".to_string(), + namespace: "/admin".to_string(), + rooms: HashSet::from(["room1".to_string()]), + }; + + let encoded = serde_json::to_vec(&info).unwrap(); + let decoded: SocketInfo = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded.sid, info.sid); + assert_eq!(decoded.namespace, info.namespace); +} + +#[test] +fn test_session_info_serialization() { + let info = SessionInfo { + sid: "sid-1".to_string(), + transport: "websocket".to_string(), + state: "open".to_string(), + server_id: "server-1".to_string(), + created_at: 1234567890, + last_ping: 1234567900, + }; + + let encoded = serde_json::to_vec(&info).unwrap(); + let decoded: SessionInfo = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded.sid, info.sid); + assert_eq!(decoded.transport, info.transport); + assert_eq!(decoded.state, info.state); +} + +#[test] +fn test_message_bus_error_display() { + let err = imks::socket::message_bus::MessageBusError::Redis("connection refused".to_string()); + assert_eq!(format!("{}", err), "Redis error: connection refused"); + + let err = imks::socket::message_bus::MessageBusError::Nats("timeout".to_string()); + assert_eq!(format!("{}", err), "NATS error: timeout"); +} + +#[test] +fn test_adapter_error_display() { + let err = AdapterError::Redis("SADD failed".to_string()); + assert_eq!(format!("{}", err), "Redis error: SADD failed"); + + let err = AdapterError::Nats("publish failed".to_string()); + assert_eq!(format!("{}", err), "NATS error: publish failed"); + + let err = AdapterError::Serialization("json error".to_string()); + assert_eq!(format!("{}", err), "Serialization error: json error"); +} + +#[test] +fn test_session_error_display() { + let err = imks::socket::session_store::SessionError::Redis("timeout".to_string()); + assert_eq!(format!("{}", err), "Redis error: timeout"); + + let err = imks::socket::session_store::SessionError::NotFound("sid-1".to_string()); + assert_eq!(format!("{}", err), "Session not found: sid-1"); +} + +#[test] +fn test_is_valid_namespace() { + assert!(imks::socket::namespace::is_valid_namespace("/")); + assert!(imks::socket::namespace::is_valid_namespace("/admin")); + assert!(imks::socket::namespace::is_valid_namespace("/chat/room1")); + + assert!(!imks::socket::namespace::is_valid_namespace("")); + assert!(!imks::socket::namespace::is_valid_namespace("admin")); + assert!(!imks::socket::namespace::is_valid_namespace(&"/".repeat(257))); +} diff --git a/tests/engine_io_tests.rs b/tests/engine_io_tests.rs new file mode 100644 index 0000000..67375b4 --- /dev/null +++ b/tests/engine_io_tests.rs @@ -0,0 +1,158 @@ +use imks::engine::codec; +use imks::engine::packet::{HandshakeData, Packet, PacketData, PacketType}; + +#[test] +fn test_engine_io_handshake_encoding() { + let handshake = HandshakeData { + sid: "lv_VI97HAXpY6yYWAAAC".to_string(), + upgrades: vec!["websocket".to_string()], + ping_interval: 25000, + ping_timeout: 20000, + max_payload: 1000000, + }; + + let packet = Packet::open(&handshake); + let encoded = codec::encode_packet(&packet); + + assert!(encoded.starts_with('0')); + assert!(encoded.contains("\"sid\":\"lv_VI97HAXpY6yYWAAAC\"")); + assert!(encoded.contains("\"upgrades\":[\"websocket\"]")); + assert!(encoded.contains("\"pingInterval\":25000")); + assert!(encoded.contains("\"pingTimeout\":20000")); + assert!(encoded.contains("\"maxPayload\":1000000")); +} + +#[test] +fn test_engine_io_packet_types() { + let open = Packet::open(&HandshakeData { + sid: "test".to_string(), + upgrades: vec![], + ping_interval: 25000, + ping_timeout: 20000, + max_payload: 1000000, + }); + assert_eq!(open.packet_type, PacketType::Open); + + let close = Packet::close(); + assert_eq!(close.packet_type, PacketType::Close); + + let ping = Packet::ping("test"); + assert_eq!(ping.packet_type, PacketType::Ping); + + let pong = Packet::pong("test"); + assert_eq!(pong.packet_type, PacketType::Pong); + + let msg = Packet::message_text("hello"); + assert_eq!(msg.packet_type, PacketType::Message); + + let upgrade = Packet::upgrade(); + assert_eq!(upgrade.packet_type, PacketType::Upgrade); + + let noop = Packet::noop(); + assert_eq!(noop.packet_type, PacketType::Noop); +} + +#[test] +fn test_engine_io_polling_payload() { + let packets = vec![ + Packet::message_text("hello"), + Packet::ping(""), + Packet::message_text("world"), + ]; + + let encoded = codec::encode_payload(&packets); + assert_eq!(encoded, "4hello\x1e2\x1e4world"); + + let decoded = codec::decode_payload(&encoded).unwrap(); + assert_eq!(decoded.len(), 3); + assert_eq!(decoded[0].packet_type, PacketType::Message); + assert_eq!(decoded[1].packet_type, PacketType::Ping); + assert_eq!(decoded[2].packet_type, PacketType::Message); +} + +#[test] +fn test_engine_io_binary_encoding() { + let packet = Packet::message_binary(vec![0x01, 0x02, 0x03, 0x04]); + let encoded = codec::encode_packet(&packet); + assert_eq!(encoded, "4bAQIDBA=="); + + let decoded = codec::decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Message); + assert_eq!(decoded.data, PacketData::Binary(vec![0x01, 0x02, 0x03, 0x04])); +} + +#[test] +fn test_engine_io_webtransport_header_encoding() { + let header = codec::encode_webtransport_header(6, false); + assert_eq!(header, vec![0x06]); + + let header = codec::encode_webtransport_header(200, true); + assert_eq!(header.len(), 3); + assert_eq!(header[0], 0x80 | 126); + + let header = codec::encode_webtransport_header(70000, false); + assert_eq!(header.len(), 9); + assert_eq!(header[0], 127); +} + +#[test] +fn test_engine_io_webtransport_header_decoding() { + let header = vec![0x06]; + let (len, is_binary) = codec::decode_webtransport_header(&header).unwrap(); + assert_eq!(len, 6); + assert!(!is_binary); + + let header = vec![0x80 | 126, 0x00, 0xC8]; + let (len, is_binary) = codec::decode_webtransport_header(&header).unwrap(); + assert_eq!(len, 200); + assert!(is_binary); +} + +#[test] +fn test_engine_io_probe_ping_pong() { + let ping = Packet::ping("probe"); + let encoded = codec::encode_packet(&ping); + assert_eq!(encoded, "2probe"); + + let decoded = codec::decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Ping); + assert_eq!(decoded.data, PacketData::Text("probe".to_string())); + + let pong = Packet::pong("probe"); + let encoded = codec::encode_packet(&pong); + assert_eq!(encoded, "3probe"); + + let decoded = codec::decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Pong); + assert_eq!(decoded.data, PacketData::Text("probe".to_string())); +} + +#[test] +fn test_engine_io_upgrade_packet() { + let upgrade = Packet::upgrade(); + let encoded = codec::encode_packet(&upgrade); + assert_eq!(encoded, "5"); + + let decoded = codec::decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Upgrade); +} + +#[test] +fn test_engine_io_noop_packet() { + let noop = Packet::noop(); + let encoded = codec::encode_packet(&noop); + assert_eq!(encoded, "6"); + + let decoded = codec::decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Noop); +} + +#[test] +fn test_engine_io_close_packet() { + let close = Packet::close(); + let encoded = codec::encode_packet(&close); + assert_eq!(encoded, "1"); + + let decoded = codec::decode_packet(&encoded).unwrap(); + assert_eq!(decoded.packet_type, PacketType::Close); +} diff --git a/tests/session_tests.rs b/tests/session_tests.rs new file mode 100644 index 0000000..ee39167 --- /dev/null +++ b/tests/session_tests.rs @@ -0,0 +1,129 @@ +use imks::engine::session::{generate_sid, SessionState, SessionStore, TransportType}; + +#[test] +fn test_session_store_create_and_get() { + let store = SessionStore::new(); + let sid = generate_sid(); + + let _rx = store.create(sid.clone(), TransportType::Polling); + + assert!(store.exists(&sid)); + assert!(store.get(&sid).is_some()); + assert_eq!(store.len(), 1); +} + +#[test] +fn test_session_store_remove() { + let store = SessionStore::new(); + let sid = generate_sid(); + + let _rx = store.create(sid.clone(), TransportType::Polling); + assert!(store.exists(&sid)); + + store.remove(&sid); + assert!(!store.exists(&sid)); + assert!(store.get(&sid).is_none()); +} + +#[test] +fn test_session_store_multiple_sessions() { + let store = SessionStore::new(); + + let sid1 = generate_sid(); + let sid2 = generate_sid(); + let sid3 = generate_sid(); + + let _rx1 = store.create(sid1.clone(), TransportType::Polling); + let _rx2 = store.create(sid2.clone(), TransportType::WebSocket); + let _rx3 = store.create(sid3.clone(), TransportType::WebTransport); + + assert_eq!(store.len(), 3); + assert!(store.exists(&sid1)); + assert!(store.exists(&sid2)); + assert!(store.exists(&sid3)); +} + +#[test] +fn test_generate_sid_uniqueness() { + let sids: Vec = (0..100).map(|_| generate_sid()).collect(); + + let unique_sids: std::collections::HashSet = sids.into_iter().collect(); + assert_eq!(unique_sids.len(), 100); +} + +#[test] +fn test_generate_sid_format() { + let sid = generate_sid(); + + assert_eq!(sid.len(), 20); + assert!(sid.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')); +} + +#[tokio::test] +async fn test_session_state_transitions() { + let store = SessionStore::new(); + let sid = generate_sid(); + + let _rx = store.create(sid.clone(), TransportType::Polling); + + if let Some(session) = store.get(&sid) { + let mut session = session.write().await; + assert_eq!(session.state, SessionState::Connecting); + + session.set_state(SessionState::Open); + assert_eq!(session.state, SessionState::Open); + + session.set_state(SessionState::Upgrading); + assert_eq!(session.state, SessionState::Upgrading); + + session.set_state(SessionState::Open); + assert_eq!(session.state, SessionState::Open); + + session.set_state(SessionState::Closing); + assert_eq!(session.state, SessionState::Closing); + + session.set_state(SessionState::Closed); + assert_eq!(session.state, SessionState::Closed); + } +} + +#[tokio::test] +async fn test_session_transport_change() { + let store = SessionStore::new(); + let sid = generate_sid(); + + let _rx = store.create(sid.clone(), TransportType::Polling); + + if let Some(session) = store.get(&sid) { + let mut session = session.write().await; + assert_eq!(session.transport, TransportType::Polling); + + session.set_transport(TransportType::WebSocket); + assert_eq!(session.transport, TransportType::WebSocket); + } +} + +#[tokio::test] +async fn test_session_ping_update() { + let store = SessionStore::new(); + let sid = generate_sid(); + + let _rx = store.create(sid.clone(), TransportType::Polling); + + if let Some(session) = store.get(&sid) { + let mut session = session.write().await; + let initial_ping = session.last_ping; + + std::thread::sleep(std::time::Duration::from_millis(10)); + session.update_ping(); + + assert!(session.last_ping > initial_ping); + } +} + +#[test] +fn test_transport_type_as_str() { + assert_eq!(TransportType::Polling.as_str(), "polling"); + assert_eq!(TransportType::WebSocket.as_str(), "websocket"); + assert_eq!(TransportType::WebTransport.as_str(), "webtransport"); +} diff --git a/tests/socket_io_tests.rs b/tests/socket_io_tests.rs new file mode 100644 index 0000000..d9c3184 --- /dev/null +++ b/tests/socket_io_tests.rs @@ -0,0 +1,203 @@ +use imks::socket::packet::{Packet, PacketType}; +use imks::socket::parser; +use serde_json::json; + +#[test] +fn test_socket_io_connect_encoding() { + let packet = Packet::connect("/", None); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "0"); + + let packet = Packet::connect("/admin", Some(json!({"sid": "abc123"}))); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "0/admin,{\"sid\":\"abc123\"}"); +} + +#[test] +fn test_socket_io_disconnect_encoding() { + let packet = Packet::disconnect("/"); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "1"); + + let packet = Packet::disconnect("/admin"); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "1/admin,"); +} + +#[test] +fn test_socket_io_event_encoding() { + let packet = Packet::event("/", json!(["foo"]), None); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "2[\"foo\"]"); + + let packet = Packet::event("/admin", json!(["bar"]), None); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "2/admin,[\"bar\"]"); +} + +#[test] +fn test_socket_io_event_with_ack_encoding() { + let packet = Packet::event("/", json!(["foo"]), Some(12)); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "212[\"foo\"]"); + + let packet = Packet::event("/admin", json!(["bar"]), Some(13)); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "2/admin,13[\"bar\"]"); +} + +#[test] +fn test_socket_io_ack_encoding() { + let packet = Packet::ack("/", json!([]), 12); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "312[]"); + + let packet = Packet::ack("/admin", json!(["bar"]), 13); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "3/admin,13[\"bar\"]"); +} + +#[test] +fn test_socket_io_connect_error_encoding() { + let packet = Packet::connect_error("/", "Not authorized"); + let encoded = parser::encode(&packet); + assert_eq!(encoded, "4{\"message\":\"Not authorized\"}"); +} + +#[test] +fn test_socket_io_connect_decoding() { + let packet = parser::decode("0").unwrap(); + assert_eq!(packet.packet_type, PacketType::Connect); + assert_eq!(packet.namespace, "/"); + assert!(packet.data.is_none()); +} + +#[test] +fn test_socket_io_connect_with_namespace_decoding() { + let packet = parser::decode("0/admin,{\"sid\":\"abc\"}").unwrap(); + assert_eq!(packet.packet_type, PacketType::Connect); + assert_eq!(packet.namespace, "/admin"); + assert!(packet.data.is_some()); +} + +#[test] +fn test_socket_io_disconnect_decoding() { + let packet = parser::decode("1").unwrap(); + assert_eq!(packet.packet_type, PacketType::Disconnect); + assert_eq!(packet.namespace, "/"); +} + +#[test] +fn test_socket_io_disconnect_with_namespace_decoding() { + let packet = parser::decode("1/admin,").unwrap(); + assert_eq!(packet.packet_type, PacketType::Disconnect); + assert_eq!(packet.namespace, "/admin"); +} + +#[test] +fn test_socket_io_event_decoding() { + let packet = parser::decode("2[\"foo\"]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Event); + assert_eq!(packet.namespace, "/"); + assert_eq!(packet.data, Some(json!(["foo"]))); +} + +#[test] +fn test_socket_io_event_with_namespace_decoding() { + let packet = parser::decode("2/admin,[\"bar\"]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Event); + assert_eq!(packet.namespace, "/admin"); + assert_eq!(packet.data, Some(json!(["bar"]))); +} + +#[test] +fn test_socket_io_event_with_ack_decoding() { + let packet = parser::decode("212[\"foo\"]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Event); + assert_eq!(packet.id, Some(12)); + assert_eq!(packet.data, Some(json!(["foo"]))); +} + +#[test] +fn test_socket_io_ack_decoding() { + let packet = parser::decode("312[]").unwrap(); + assert_eq!(packet.packet_type, PacketType::Ack); + assert_eq!(packet.id, Some(12)); +} + +#[test] +fn test_socket_io_connect_error_decoding() { + let packet = parser::decode("4{\"message\":\"Not authorized\"}").unwrap(); + assert_eq!(packet.packet_type, PacketType::ConnectError); + assert_eq!(packet.data, Some(json!({"message": "Not authorized"}))); +} + +#[test] +fn test_socket_io_binary_event_encoding() { + let packet = Packet::binary_event( + "/", + json!(["baz", {"_placeholder": true, "num": 0}]), + None, + vec![vec![0x01, 0x02, 0x03, 0x04]], + ); + let encoded = parser::encode(&packet); + assert!(encoded.starts_with("51-")); +} + +#[test] +fn test_socket_io_binary_ack_encoding() { + let packet = Packet::binary_ack( + "/", + json!(["bar", {"_placeholder": true, "num": 0}]), + 15, + vec![vec![0x01, 0x02, 0x03, 0x04]], + ); + let encoded = parser::encode(&packet); + assert!(encoded.starts_with("61-")); + assert!(encoded.contains("15")); +} + +#[test] +fn test_socket_io_roundtrip() { + let original = Packet::event("/admin", json!(["hello", "world"]), Some(42)); + let encoded = parser::encode(&original); + let decoded = parser::decode(&encoded).unwrap(); + + assert_eq!(decoded.packet_type, original.packet_type); + assert_eq!(decoded.namespace, original.namespace); + assert_eq!(decoded.id, original.id); + assert_eq!(decoded.data, original.data); +} + +#[test] +fn test_socket_io_namespace_handling() { + let namespaces = vec!["/", "/admin", "/chat", "/api/v1"]; + + for ns in namespaces { + let packet = Packet::event(ns, json!(["test"]), None); + let encoded = parser::encode(&packet); + let decoded = parser::decode(&encoded).unwrap(); + assert_eq!(decoded.namespace, ns); + } +} + +#[test] +fn test_socket_io_complex_data() { + let complex_data = json!([ + "event_name", + { + "user": { + "id": 123, + "name": "test", + "roles": ["admin", "user"] + }, + "timestamp": 1234567890 + } + ]); + + let packet = Packet::event("/", complex_data.clone(), None); + let encoded = parser::encode(&packet); + let decoded = parser::decode(&encoded).unwrap(); + + assert_eq!(decoded.data, Some(complex_data)); +}