From b293bd11ffa2947e157f44605239764a7b1f33ac Mon Sep 17 00:00:00 2001 From: totodore Date: Tue, 10 Jun 2025 00:30:25 +0200 Subject: [PATCH 01/17] wip --- Cargo.lock | 33 ++ crates/engineioxide-client/Cargo.toml | 55 ++++ crates/engineioxide-client/README.md | 0 crates/engineioxide-client/src/lib.rs | 0 crates/engineioxide-core/Cargo.toml | 1 + crates/engineioxide-core/src/lib.rs | 3 + crates/engineioxide-core/src/packet.rs | 354 ++++++++++++++++++++++ crates/engineioxide-core/src/transport.rs | 57 ++++ crates/engineioxide/src/service/parser.rs | 55 +--- 9 files changed, 508 insertions(+), 50 deletions(-) create mode 100644 crates/engineioxide-client/Cargo.toml create mode 100644 crates/engineioxide-client/README.md create mode 100644 crates/engineioxide-client/src/lib.rs create mode 100644 crates/engineioxide-core/src/packet.rs create mode 100644 crates/engineioxide-core/src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 21e31591..2598ecf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,38 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "engineioxide-client" +version = "0.17.0" +dependencies = [ + "axum", + "base64 0.22.1", + "bytes", + "criterion", + "engineioxide-core", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "memchr", + "pin-project-lite", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-subscriber", + "unicode-segmentation", +] + [[package]] name = "engineioxide-core" version = "0.2.0" @@ -683,6 +715,7 @@ dependencies = [ "bytes", "rand 0.9.1", "serde", + "serde_json", ] [[package]] diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml new file mode 100644 index 00000000..6d18b4dd --- /dev/null +++ b/crates/engineioxide-client/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "engineioxide-client" +description = "Engine IO client implementation in rust" +version = "0.17.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license.workspace = true +readme = "README.md" + +[dependencies] +engineioxide-core = { path = "../engineioxide-core", version = "0.2" } +bytes.workspace = true +futures-core.workspace = true +futures-util.workspace = true +http.workspace = true +http-body.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt", "time"] } +hyper.workspace = true +tokio-tungstenite.workspace = true +http-body-util.workspace = true +pin-project-lite.workspace = true +smallvec.workspace = true +hyper-util = { workspace = true, features = ["tokio"] } + +base64 = "0.22" + +# Tracing +tracing = { workspace = true, optional = true } + +# Engine.io V3 payload +itoa = { workspace = true, optional = true } +memchr = { version = "2.7", optional = true } +unicode-segmentation = { version = "1.12", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "parking_lot"] } +tracing-subscriber.workspace = true +hyper = { workspace = true, features = ["server", "http1"] } +criterion.workspace = true +axum.workspace = true +tokio-stream.workspace = true +tokio-util.workspace = true + +[features] +v3 = ["memchr", "unicode-segmentation", "itoa"] +tracing = ["dep:tracing"] +__test_harness = [] diff --git a/crates/engineioxide-client/README.md b/crates/engineioxide-client/README.md new file mode 100644 index 00000000..e69de29b diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index 58797493..42daaf7a 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -17,3 +17,4 @@ rand = "0.9" base64 = "0.22" serde.workspace = true bytes.workspace = true +serde_json.workspace = true diff --git a/crates/engineioxide-core/src/lib.rs b/crates/engineioxide-core/src/lib.rs index 822f89c0..5f4e2ef8 100644 --- a/crates/engineioxide-core/src/lib.rs +++ b/crates/engineioxide-core/src/lib.rs @@ -30,8 +30,11 @@ )] #![doc = include_str!("../README.md")] +mod packet; mod sid; mod str; +mod transport; pub use sid::Sid; pub use str::Str; +pub use transport::{TransportType, UnknownTransportError}; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs new file mode 100644 index 00000000..0c85a728 --- /dev/null +++ b/crates/engineioxide-core/src/packet.rs @@ -0,0 +1,354 @@ +use std::fmt; + +use base64::{Engine, engine::general_purpose}; +use bytes::Bytes; +use serde::Serialize; + +use crate::{Sid, Str}; + +/// A Packet type to use when receiving and sending data from the client +#[derive(Debug, Clone, PartialEq, PartialOrd)] +pub enum Packet { + /// Open packet used to initiate a connection + Open(OpenPacket), + /// Close packet used to close a connection + Close, + /// Ping packet used to check if the connection is still alive + /// The client never sends this packet, it is only used by the server + Ping, + /// Pong packet used to respond to a Ping packet + /// The server never sends this packet, it is only used by the client + Pong, + + /// Special Ping packet used to initiate a connection + PingUpgrade, + /// Special Pong packet used to respond to a PingUpgrade packet and upgrade the connection + PongUpgrade, + + /// Message packet used to send a message to the client + Message(Str), + /// Upgrade packet to upgrade the connection from polling to websocket + Upgrade, + + /// Noop packet used to send something to a opened polling connection so it gracefully closes to allow the client to upgrade to websocket + Noop, + + /// Binary packet used to send binary data to the client + /// Converts to a String using base64 encoding when using polling connection + /// Or to a websocket binary frame when using websocket connection + /// + /// When receiving, it is only used with polling connection, websocket use binary frame + Binary(Bytes), // Not part of the protocol, used internally + + /// Binary packet used to send binary data to the client + /// Converts to a String using base64 encoding when using polling connection + /// Or to a websocket binary frame when using websocket connection + /// + /// When receiving, it is only used with polling connection, websocket use binary frame + /// + /// This is a special packet, excepionally specific to the V3 protocol. + BinaryV3(Bytes), // Not part of the protocol, used internally +} + +#[derive(Debug)] +pub enum PacketParseError { + InvalidPacketType(Option), + InvalidPacketPayload, + Base64Decode(base64::DecodeError), +} +impl fmt::Display for PacketParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PacketParseError::InvalidPacketType(c) => write!(f, "Invalid packet type: {:?}", c), + PacketParseError::InvalidPacketPayload => write!(f, "Invalid packet payload"), + PacketParseError::Base64Decode(err) => write!(f, "Base64 decode error: {}", err), + } + } +} +impl From for PacketParseError { + fn from(err: base64::DecodeError) -> Self { + PacketParseError::Base64Decode(err) + } +} +impl std::error::Error for PacketParseError {} + +impl Packet { + /// Check if the packet is a binary packet + pub fn is_binary(&self) -> bool { + matches!(self, Packet::Binary(_) | Packet::BinaryV3(_)) + } + + /// If the packet is a message packet (text), it returns the message + pub fn into_message(self) -> Str { + match self { + Packet::Message(msg) => msg, + _ => panic!("Packet is not a message"), + } + } + + /// If the packet is a binary packet, it returns the binary data + pub fn into_binary(self) -> Bytes { + match self { + Packet::Binary(data) => data, + Packet::BinaryV3(data) => data, + _ => panic!("Packet is not a binary"), + } + } + + /// Get the max size the packet could have when serialized + /// + /// If b64 is true, it returns the max size when serialized to base64 + /// + /// The base64 max size factor is `ceil(n / 3) * 4` + pub(crate) fn get_size_hint(&self, b64: bool) -> usize { + match self { + Packet::Open(_) => 156, // max possible size for the open packet serialized + Packet::Close => 1, + Packet::Ping => 1, + Packet::Pong => 1, + Packet::PingUpgrade => 6, + Packet::PongUpgrade => 6, + Packet::Message(msg) => 1 + msg.len(), + Packet::Upgrade => 1, + Packet::Noop => 1, + Packet::Binary(data) => { + if b64 { + 1 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 1) + } else { + 1 + data.len() + } + } + Packet::BinaryV3(data) => { + if b64 { + 2 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 2) + } else { + 1 + data.len() + } + } + } + } +} + +/// Serialize a [Packet] to a [String] according to the Engine.IO protocol +impl From for String { + fn from(packet: Packet) -> String { + let len = packet.get_size_hint(true); + let mut buffer = String::with_capacity(len); + match packet { + Packet::Open(open) => { + buffer.push('0'); + buffer.push_str(&serde_json::to_string(&open).unwrap()); + } + Packet::Close => buffer.push('1'), + Packet::Ping => buffer.push('2'), + Packet::Pong => buffer.push('3'), + Packet::PingUpgrade => buffer.push_str("2probe"), + Packet::PongUpgrade => buffer.push_str("3probe"), + Packet::Message(msg) => { + buffer.push('4'); + buffer.push_str(&msg); + } + Packet::Upgrade => buffer.push('5'), + Packet::Noop => buffer.push('6'), + Packet::Binary(data) => { + buffer.push('b'); + general_purpose::STANDARD.encode_string(data, &mut buffer); + } + Packet::BinaryV3(data) => { + buffer.push_str("b4"); + general_purpose::STANDARD.encode_string(data, &mut buffer); + } + }; + buffer + } +} + +/// Deserialize a [Packet] from a [String] according to the Engine.IO protocol +impl TryFrom for Packet { + type Error = PacketParseError; + fn try_from(value: Str) -> Result { + let packet_type = value + .as_bytes() + .first() + .ok_or(PacketParseError::InvalidPacketType(None))?; + let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; + let res = match packet_type { + b'1' => Packet::Close, + b'2' if is_upgrade => Packet::PingUpgrade, + b'2' => Packet::Ping, + b'3' if is_upgrade => Packet::PongUpgrade, + b'3' => Packet::Pong, + b'4' => Packet::Message(value.slice(1..)), + b'5' => Packet::Upgrade, + b'6' => Packet::Noop, + b'b' if value.as_bytes().get(1) == Some(&b'4') => Packet::BinaryV3( + general_purpose::STANDARD + .decode(value.slice(2..).as_bytes())? + .into(), + ), + b'b' => Packet::Binary( + general_purpose::STANDARD + .decode(value.slice(1..).as_bytes())? + .into(), + ), + c => Err(PacketParseError::InvalidPacketType(Some(*c as char)))?, + }; + Ok(res) + } +} + +impl TryFrom for Packet { + type Error = PacketParseError; + fn try_from(value: String) -> Result { + Packet::try_from(Str::from(value)) + } +} + +/// An OpenPacket is used to initiate a connection +#[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] +#[serde(rename_all = "camelCase")] +pub struct OpenPacket { + sid: Sid, + upgrades: Vec, + ping_interval: u64, + ping_timeout: u64, + max_payload: u64, +} + +// impl OpenPacket { +// /// Create a new [OpenPacket] +// /// If the current transport is polling, the server will always allow the client to upgrade to websocket +// pub fn new(transport: TransportType, sid: Sid, config: &EngineIoConfig) -> Self { +// let upgrades = if transport == TransportType::Polling { +// vec!["websocket".to_string()] +// } else { +// vec![] +// }; +// OpenPacket { +// sid, +// upgrades, +// ping_interval: config.ping_interval.as_millis() as u64, +// ping_timeout: config.ping_timeout.as_millis() as u64, +// max_payload: config.max_payload, +// } +// } +// } + +#[cfg(test)] +mod tests { + use crate::config::EngineIoConfig; + + use super::*; + use std::{convert::TryInto, time::Duration}; + + #[test] + fn test_open_packet() { + let sid = Sid::new(); + let packet = Packet::Open(OpenPacket::new( + TransportType::Polling, + sid, + &EngineIoConfig::default(), + )); + let packet_str: String = packet.into(); + assert_eq!( + packet_str, + format!( + "0{{\"sid\":\"{sid}\",\"upgrades\":[\"websocket\"],\"pingInterval\":25000,\"pingTimeout\":20000,\"maxPayload\":100000}}" + ) + ); + } + + #[test] + fn test_message_packet() { + let packet = Packet::Message("hello".into()); + let packet_str: String = packet.into(); + assert_eq!(packet_str, "4hello"); + } + + #[test] + fn test_message_packet_deserialize() { + let packet_str = "4hello".to_string(); + let packet: Packet = packet_str.try_into().unwrap(); + assert_eq!(packet, Packet::Message("hello".into())); + } + + #[test] + fn test_binary_packet() { + let packet = Packet::Binary(vec![1, 2, 3].into()); + let packet_str: String = packet.into(); + assert_eq!(packet_str, "bAQID"); + } + + #[test] + fn test_binary_packet_deserialize() { + let packet_str = "bAQID".to_string(); + let packet: Packet = packet_str.try_into().unwrap(); + assert_eq!(packet, Packet::Binary(vec![1, 2, 3].into())); + } + + #[test] + fn test_binary_packet_v3() { + let packet = Packet::BinaryV3(vec![1, 2, 3].into()); + let packet_str: String = packet.into(); + assert_eq!(packet_str, "b4AQID"); + } + + #[test] + fn test_binary_packet_v3_deserialize() { + let packet_str = "b4AQID".to_string(); + let packet: Packet = packet_str.try_into().unwrap(); + assert_eq!(packet, Packet::BinaryV3(vec![1, 2, 3].into())); + } + + #[test] + fn test_packet_get_size_hint() { + // Max serialized packet + let open = OpenPacket::new( + TransportType::Polling, + Sid::new(), + &EngineIoConfig { + max_buffer_size: usize::MAX, + max_payload: u64::MAX, + ping_interval: Duration::MAX, + ping_timeout: Duration::MAX, + transports: TransportType::Polling as u8 | TransportType::Websocket as u8, + ..Default::default() + }, + ); + let size = serde_json::to_string(&open).unwrap().len(); + let packet = Packet::Open(open); + assert_eq!(packet.get_size_hint(false), size); + + let packet = Packet::Close; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Ping; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Pong; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::PingUpgrade; + assert_eq!(packet.get_size_hint(false), 6); + + let packet = Packet::PongUpgrade; + assert_eq!(packet.get_size_hint(false), 6); + + let packet = Packet::Message("hello".into()); + assert_eq!(packet.get_size_hint(false), 6); + + let packet = Packet::Upgrade; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Noop; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Binary(vec![1, 2, 3].into()); + assert_eq!(packet.get_size_hint(false), 4); + assert_eq!(packet.get_size_hint(true), 5); + + let packet = Packet::BinaryV3(vec![1, 2, 3].into()); + assert_eq!(packet.get_size_hint(false), 4); + assert_eq!(packet.get_size_hint(true), 6); + } +} diff --git a/crates/engineioxide-core/src/transport.rs b/crates/engineioxide-core/src/transport.rs new file mode 100644 index 00000000..7093d9ff --- /dev/null +++ b/crates/engineioxide-core/src/transport.rs @@ -0,0 +1,57 @@ +use std::str::FromStr; + +/// The type of `transport` used to connect to the client/server. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum TransportType { + /// Polling transport + Polling = 0x01, + /// Websocket transport + Websocket = 0x02, +} + +impl From for TransportType { + fn from(t: u8) -> Self { + match t { + 0x01 => TransportType::Polling, + 0x02 => TransportType::Websocket, + _ => panic!("unknown transport type"), + } + } +} + +#[derive(Debug, Copy, Clone)] +pub struct UnknownTransportError; +impl std::fmt::Display for UnknownTransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown transport type") + } +} +impl std::error::Error for UnknownTransportError {} + +impl FromStr for TransportType { + type Err = UnknownTransportError; + + fn from_str(s: &str) -> Result { + match s { + "websocket" => Ok(TransportType::Websocket), + "polling" => Ok(TransportType::Polling), + _ => Err(UnknownTransportError), + } + } +} +impl From for &'static str { + fn from(t: TransportType) -> Self { + match t { + TransportType::Polling => "polling", + TransportType::Websocket => "websocket", + } + } +} +impl From for String { + fn from(t: TransportType) -> Self { + match t { + TransportType::Polling => "polling".into(), + TransportType::Websocket => "websocket".into(), + } + } +} diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index e911d3d5..5adf3594 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -4,7 +4,7 @@ use std::{future::Future, str::FromStr, sync::Arc}; use http::{Method, Request, Response}; -use engineioxide_core::Sid; +use engineioxide_core::{Sid, TransportType}; use crate::{ body::ResponseBody, @@ -82,7 +82,7 @@ where #[derive(thiserror::Error, Debug)] pub enum ParseError { #[error("transport unknown")] - UnknownTransport, + UnknownTransport(#[from] engineioxide_core::UnknownTransportError), #[error("bad handshake method")] BadHandshakeMethod, #[error("transport mismatch")] @@ -105,7 +105,9 @@ impl From for Response> { .unwrap() }; match err { - UnknownTransport => conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}"), + UnknownTransport(_) => { + conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}") + } BadHandshakeMethod => { conn_err_resp("{\"code\":\"2\",\"message\":\"Bad handshake method\"}") } @@ -147,53 +149,6 @@ impl FromStr for ProtocolVersion { } } -/// The type of `transport` used by the client. -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum TransportType { - /// Polling transport - Polling = 0x01, - /// Websocket transport - Websocket = 0x02, -} - -impl From for TransportType { - fn from(t: u8) -> Self { - match t { - 0x01 => TransportType::Polling, - 0x02 => TransportType::Websocket, - _ => panic!("unknown transport type"), - } - } -} - -impl FromStr for TransportType { - type Err = ParseError; - - fn from_str(s: &str) -> Result { - match s { - "websocket" => Ok(TransportType::Websocket), - "polling" => Ok(TransportType::Polling), - _ => Err(ParseError::UnknownTransport), - } - } -} -impl From for &'static str { - fn from(t: TransportType) -> Self { - match t { - TransportType::Polling => "polling", - TransportType::Websocket => "websocket", - } - } -} -impl From for String { - fn from(t: TransportType) -> Self { - match t { - TransportType::Polling => "polling".into(), - TransportType::Websocket => "websocket".into(), - } - } -} - /// The request information extracted from the request URI. #[derive(Debug)] pub struct RequestInfo { From cb13f2f125ec1b11d00b58de2c888648a189502d Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 20 Jul 2025 21:17:13 +0200 Subject: [PATCH 02/17] feat(engineio): refactor shared types to core --- crates/engineioxide-client/src/lib.rs | 3 + crates/engineioxide-core/src/lib.rs | 1 + crates/engineioxide-core/src/packet.rs | 115 +++--- crates/engineioxide-core/src/transport.rs | 1 + crates/engineioxide/benches/packet_encode.rs | 8 +- crates/engineioxide/src/config.rs | 2 +- crates/engineioxide/src/engine.rs | 4 +- crates/engineioxide/src/errors.rs | 42 +-- crates/engineioxide/src/lib.rs | 5 +- crates/engineioxide/src/packet.rs | 344 ------------------ crates/engineioxide/src/peekable.rs | 2 +- crates/engineioxide/src/service/mod.rs | 2 +- crates/engineioxide/src/service/parser.rs | 15 +- crates/engineioxide/src/socket.rs | 13 +- crates/engineioxide/src/transport/mod.rs | 19 + .../engineioxide/src/transport/polling/mod.rs | 11 +- .../src/transport/polling/payload/decoder.rs | 80 ++-- .../src/transport/polling/payload/encoder.rs | 3 +- .../src/transport/polling/payload/mod.rs | 7 +- crates/engineioxide/src/transport/ws.rs | 33 +- 20 files changed, 214 insertions(+), 496 deletions(-) delete mode 100644 crates/engineioxide/src/packet.rs diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index e69de29b..0689dcf1 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -0,0 +1,3 @@ +#![warn(clippy::pedantic)] + +//! Engine.IO client library for Rust. diff --git a/crates/engineioxide-core/src/lib.rs b/crates/engineioxide-core/src/lib.rs index 8e80eaa9..3149548c 100644 --- a/crates/engineioxide-core/src/lib.rs +++ b/crates/engineioxide-core/src/lib.rs @@ -34,6 +34,7 @@ mod sid; mod str; mod transport; +pub use packet::{OpenPacket, Packet, PacketParseError}; pub use sid::Sid; pub use str::Str; pub use transport::{TransportType, UnknownTransportError}; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 0c85a728..4ce60300 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -50,18 +50,39 @@ pub enum Packet { BinaryV3(Bytes), // Not part of the protocol, used internally } +/// An error that occurs when parsing a packet. #[derive(Debug)] pub enum PacketParseError { + /// The packet type is invalid. InvalidPacketType(Option), + /// The packet payload is invalid. InvalidPacketPayload, + /// The packet length is invalid. + InvalidPacketLen, + /// The packet chunk is invalid + InvalidUtf8Boundary(std::str::Utf8Error), + /// The base64 decoding failed. Base64Decode(base64::DecodeError), + /// The payload is too large. + PayloadTooLarge { + /// The maximum allowed payload size. + max: u64, + }, } impl fmt::Display for PacketParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - PacketParseError::InvalidPacketType(c) => write!(f, "Invalid packet type: {:?}", c), - PacketParseError::InvalidPacketPayload => write!(f, "Invalid packet payload"), - PacketParseError::Base64Decode(err) => write!(f, "Base64 decode error: {}", err), + PacketParseError::InvalidPacketType(c) => write!(f, "invalid packet type: {c:?}"), + PacketParseError::InvalidPacketPayload => write!(f, "invalid packet payload"), + PacketParseError::InvalidPacketLen => write!(f, "invalid packet length"), + PacketParseError::InvalidUtf8Boundary(err) => write!( + f, + "invalid utf8 boundary when parsing payload into packet chunks: {err}" + ), + PacketParseError::Base64Decode(err) => write!(f, "base64 decode error: {err}"), + PacketParseError::PayloadTooLarge { max } => { + write!(f, "payload too large: max {max}") + } } } } @@ -70,6 +91,16 @@ impl From for PacketParseError { PacketParseError::Base64Decode(err) } } +impl From for PacketParseError { + fn from(err: std::string::FromUtf8Error) -> Self { + PacketParseError::InvalidUtf8Boundary(err.utf8_error()) + } +} +impl From for PacketParseError { + fn from(err: std::str::Utf8Error) -> Self { + PacketParseError::InvalidUtf8Boundary(err) + } +} impl std::error::Error for PacketParseError {} impl Packet { @@ -100,7 +131,7 @@ impl Packet { /// If b64 is true, it returns the max size when serialized to base64 /// /// The base64 max size factor is `ceil(n / 3) * 4` - pub(crate) fn get_size_hint(&self, b64: bool) -> usize { + pub fn get_size_hint(&self, b64: bool) -> usize { match self { Packet::Open(_) => 156, // max possible size for the open packet serialized Packet::Close => 1, @@ -208,35 +239,34 @@ impl TryFrom for Packet { #[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] #[serde(rename_all = "camelCase")] pub struct OpenPacket { - sid: Sid, - upgrades: Vec, - ping_interval: u64, - ping_timeout: u64, - max_payload: u64, + /// The session ID. + pub sid: Sid, + /// The list of available transport upgrades. + pub upgrades: Vec, + /// The ping interval, used in the heartbeat mechanism (in milliseconds). + pub ping_interval: u64, + /// The ping timeout, used in the heartbeat mechanism (in milliseconds). + pub ping_timeout: u64, + /// The maximum number of bytes per chunk, used by the client to + /// aggregate packets into payloads. + pub max_payload: u64, } -// impl OpenPacket { -// /// Create a new [OpenPacket] -// /// If the current transport is polling, the server will always allow the client to upgrade to websocket -// pub fn new(transport: TransportType, sid: Sid, config: &EngineIoConfig) -> Self { -// let upgrades = if transport == TransportType::Polling { -// vec!["websocket".to_string()] -// } else { -// vec![] -// }; -// OpenPacket { -// sid, -// upgrades, -// ping_interval: config.ping_interval.as_millis() as u64, -// ping_timeout: config.ping_timeout.as_millis() as u64, -// max_payload: config.max_payload, -// } -// } -// } +/// This default implementation should only be used for testing purposes. +impl Default for OpenPacket { + fn default() -> Self { + Self { + sid: Sid::ZERO, + upgrades: vec!["websocket".to_string()], + ping_interval: 25000, + ping_timeout: 20000, + max_payload: 100000, + } + } +} #[cfg(test)] mod tests { - use crate::config::EngineIoConfig; use super::*; use std::{convert::TryInto, time::Duration}; @@ -244,11 +274,13 @@ mod tests { #[test] fn test_open_packet() { let sid = Sid::new(); - let packet = Packet::Open(OpenPacket::new( - TransportType::Polling, + let packet = Packet::Open(OpenPacket { sid, - &EngineIoConfig::default(), - )); + upgrades: vec!["websocket".to_string()], + ping_interval: Duration::from_millis(25000).as_millis() as u64, + ping_timeout: Duration::from_millis(20000).as_millis() as u64, + max_payload: 100000, + }); let packet_str: String = packet.into(); assert_eq!( packet_str, @@ -303,18 +335,13 @@ mod tests { #[test] fn test_packet_get_size_hint() { // Max serialized packet - let open = OpenPacket::new( - TransportType::Polling, - Sid::new(), - &EngineIoConfig { - max_buffer_size: usize::MAX, - max_payload: u64::MAX, - ping_interval: Duration::MAX, - ping_timeout: Duration::MAX, - transports: TransportType::Polling as u8 | TransportType::Websocket as u8, - ..Default::default() - }, - ); + let open = OpenPacket { + sid: Sid::new(), + ping_interval: u64::MAX, + ping_timeout: u64::MAX, + max_payload: u64::MAX, + upgrades: vec!["websocket".to_string()], + }; let size = serde_json::to_string(&open).unwrap().len(); let packet = Packet::Open(open); assert_eq!(packet.get_size_hint(false), size); diff --git a/crates/engineioxide-core/src/transport.rs b/crates/engineioxide-core/src/transport.rs index 7093d9ff..608b5e02 100644 --- a/crates/engineioxide-core/src/transport.rs +++ b/crates/engineioxide-core/src/transport.rs @@ -19,6 +19,7 @@ impl From for TransportType { } } +/// Cannot determine the transport type to connect to the client/server. #[derive(Debug, Copy, Clone)] pub struct UnknownTransportError; impl std::fmt::Display for UnknownTransportError { diff --git a/crates/engineioxide/benches/packet_encode.rs b/crates/engineioxide/benches/packet_encode.rs index 0cfc8842..f1d8f483 100644 --- a/crates/engineioxide/benches/packet_encode.rs +++ b/crates/engineioxide/benches/packet_encode.rs @@ -1,15 +1,11 @@ use bytes::Bytes; use criterion::{BatchSize, Criterion, black_box, criterion_group, criterion_main}; -use engineioxide::{OpenPacket, Packet, TransportType, config::EngineIoConfig, socket::Sid}; +use engineioxide::{OpenPacket, Packet}; fn criterion_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("engineio_packet/encode"); group.bench_function("Encode packet open", |b| { - let packet = Packet::Open(OpenPacket::new( - black_box(TransportType::Polling), - black_box(Sid::ZERO), - &EngineIoConfig::default(), - )); + let packet = Packet::Open(OpenPacket::default()); b.iter_batched( || packet.clone(), TryInto::::try_into, diff --git a/crates/engineioxide/src/config.rs b/crates/engineioxide/src/config.rs index 3eeb1fbc..abb8dece 100644 --- a/crates/engineioxide/src/config.rs +++ b/crates/engineioxide/src/config.rs @@ -32,7 +32,7 @@ use std::{borrow::Cow, time::Duration}; -use crate::service::TransportType; +use engineioxide_core::TransportType; /// Configuration for the engine.io engine & transports #[derive(Debug, Clone)] diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index 25f2d573..e98daf1f 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -3,13 +3,13 @@ use std::{ sync::{Arc, RwLock}, }; -use engineioxide_core::Sid; +use engineioxide_core::{Sid, TransportType}; use http::request::Parts; use crate::{ config::EngineIoConfig, handler::EngineIoHandler, - service::{ProtocolVersion, TransportType}, + service::ProtocolVersion, socket::{DisconnectReason, Socket}, }; diff --git a/crates/engineioxide/src/errors.rs b/crates/engineioxide/src/errors.rs index 90b976dc..fbb20136 100644 --- a/crates/engineioxide/src/errors.rs +++ b/crates/engineioxide/src/errors.rs @@ -3,26 +3,27 @@ use tokio::sync::mpsc; use tokio_tungstenite::tungstenite; use crate::body::ResponseBody; -use crate::packet::Packet; -use engineioxide_core::Sid; +use engineioxide_core::{Packet, Sid}; + +pub use engineioxide_core::PacketParseError; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("error decoding binary packet from polling request: {0:?}")] - Base64(#[from] base64::DecodeError), - #[error("error decoding packet: {0:?}")] + #[error("error decoding packet from request: {0}")] + PacketParse(#[from] PacketParseError), + #[error("error decoding packet: {0}")] StrUtf8(#[from] std::str::Utf8Error), - #[error("io error: {0:?}")] + #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("bad packet received")] BadPacket(Packet), - #[error("ws transport error: {0:?}")] + #[error("ws transport error: {0}")] WsTransport(#[from] Box), - #[error("http error: {0:?}")] + #[error("http error: {0}")] Http(#[from] http::Error), - #[error("internal channel error: {0:?}")] + #[error("internal channel error: {0}")] SendChannel(#[from] mpsc::error::TrySendError), - #[error("internal channel error: {0:?}")] + #[error("internal channel error: {0}")] RecvChannel(#[from] mpsc::error::TryRecvError), #[error("heartbeat timeout")] HeartbeatTimeout, @@ -31,20 +32,13 @@ pub enum Error { #[error("aborted connection")] Aborted, - #[error("http error response: {0:?}")] + #[error("http error response: {0}")] HttpErrorResponse(StatusCode), #[error("unknown session id")] UnknownSessionID(Sid), #[error("transport mismatch")] TransportMismatch, - #[error("payload too large")] - PayloadTooLarge, - - #[error("Invalid packet length")] - InvalidPacketLength, - #[error("Invalid packet type")] - InvalidPacketType(Option), } /// Convert an error into an http response @@ -64,16 +58,14 @@ impl From for Response> { .status(code) .body(ResponseBody::empty_response()) .unwrap(), - Error::BadPacket(_) | Error::InvalidPacketLength | Error::InvalidPacketType(_) => { - Response::builder() - .status(400) - .body(ResponseBody::empty_response()) - .unwrap() - } - Error::PayloadTooLarge => Response::builder() + Error::PacketParse(PacketParseError::PayloadTooLarge { .. }) => Response::builder() .status(413) .body(ResponseBody::empty_response()) .unwrap(), + Error::BadPacket(_) | Error::PacketParse(_) => Response::builder() + .status(400) + .body(ResponseBody::empty_response()) + .unwrap(), Error::UnknownSessionID(_) => { conn_err_resp("{\"code\":\"1\",\"message\":\"Session ID unknown\"}") diff --git a/crates/engineioxide/src/lib.rs b/crates/engineioxide/src/lib.rs index 3bf3469a..68ad1628 100644 --- a/crates/engineioxide/src/lib.rs +++ b/crates/engineioxide/src/lib.rs @@ -32,12 +32,12 @@ #![doc = include_str!("../Readme.md")] pub use engineioxide_core::Str; -pub use service::{ProtocolVersion, TransportType}; +pub use service::ProtocolVersion; pub use socket::{DisconnectReason, Socket}; #[doc(hidden)] #[cfg(feature = "__test_harness")] -pub use packet::*; +pub use engineioxide_core::{OpenPacket, Packet, PacketParseError, TransportType}; pub mod config; pub mod handler; @@ -54,6 +54,5 @@ pub mod sid { mod body; mod engine; mod errors; -mod packet; mod peekable; mod transport; diff --git a/crates/engineioxide/src/packet.rs b/crates/engineioxide/src/packet.rs deleted file mode 100644 index 40162400..00000000 --- a/crates/engineioxide/src/packet.rs +++ /dev/null @@ -1,344 +0,0 @@ -use base64::{Engine, engine::general_purpose}; -use bytes::Bytes; -use engineioxide_core::{Sid, Str}; -use serde::Serialize; - -use crate::TransportType; -use crate::config::EngineIoConfig; -use crate::errors::Error; - -/// A Packet type to use when receiving and sending data from the client -#[derive(Debug, Clone, PartialEq, PartialOrd)] -pub enum Packet { - /// Open packet used to initiate a connection - Open(OpenPacket), - /// Close packet used to close a connection - Close, - /// Ping packet used to check if the connection is still alive - /// The client never sends this packet, it is only used by the server - Ping, - /// Pong packet used to respond to a Ping packet - /// The server never sends this packet, it is only used by the client - Pong, - - /// Special Ping packet used to initiate a connection - PingUpgrade, - /// Special Pong packet used to respond to a PingUpgrade packet and upgrade the connection - PongUpgrade, - - /// Message packet used to send a message to the client - Message(Str), - /// Upgrade packet to upgrade the connection from polling to websocket - Upgrade, - - /// Noop packet used to send something to a opened polling connection so it gracefully closes to allow the client to upgrade to websocket - Noop, - - /// Binary packet used to send binary data to the client - /// Converts to a String using base64 encoding when using polling connection - /// Or to a websocket binary frame when using websocket connection - /// - /// When receiving, it is only used with polling connection, websocket use binary frame - Binary(Bytes), // Not part of the protocol, used internally - - /// Binary packet used to send binary data to the client - /// Converts to a String using base64 encoding when using polling connection - /// Or to a websocket binary frame when using websocket connection - /// - /// When receiving, it is only used with polling connection, websocket use binary frame - /// - /// This is a special packet, excepionally specific to the V3 protocol. - BinaryV3(Bytes), // Not part of the protocol, used internally -} - -impl Packet { - /// Check if the packet is a binary packet - pub fn is_binary(&self) -> bool { - matches!(self, Packet::Binary(_) | Packet::BinaryV3(_)) - } - - /// If the packet is a message packet (text), it returns the message - pub(crate) fn into_message(self) -> Str { - match self { - Packet::Message(msg) => msg, - _ => panic!("Packet is not a message"), - } - } - - /// If the packet is a binary packet, it returns the binary data - pub(crate) fn into_binary(self) -> Bytes { - match self { - Packet::Binary(data) => data, - Packet::BinaryV3(data) => data, - _ => panic!("Packet is not a binary"), - } - } - - /// Get the max size the packet could have when serialized - /// - /// If b64 is true, it returns the max size when serialized to base64 - /// - /// The base64 max size factor is `ceil(n / 3) * 4` - pub(crate) fn get_size_hint(&self, b64: bool) -> usize { - match self { - Packet::Open(_) => 156, // max possible size for the open packet serialized - Packet::Close => 1, - Packet::Ping => 1, - Packet::Pong => 1, - Packet::PingUpgrade => 6, - Packet::PongUpgrade => 6, - Packet::Message(msg) => 1 + msg.len(), - Packet::Upgrade => 1, - Packet::Noop => 1, - Packet::Binary(data) => { - if b64 { - 1 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 1) - } else { - 1 + data.len() - } - } - Packet::BinaryV3(data) => { - if b64 { - 2 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 2) - } else { - 1 + data.len() - } - } - } - } -} - -/// Serialize a [Packet] to a [String] according to the Engine.IO protocol -impl From for String { - fn from(packet: Packet) -> String { - let len = packet.get_size_hint(true); - let mut buffer = String::with_capacity(len); - match packet { - Packet::Open(open) => { - buffer.push('0'); - buffer.push_str(&serde_json::to_string(&open).unwrap()); - } - Packet::Close => buffer.push('1'), - Packet::Ping => buffer.push('2'), - Packet::Pong => buffer.push('3'), - Packet::PingUpgrade => buffer.push_str("2probe"), - Packet::PongUpgrade => buffer.push_str("3probe"), - Packet::Message(msg) => { - buffer.push('4'); - buffer.push_str(&msg); - } - Packet::Upgrade => buffer.push('5'), - Packet::Noop => buffer.push('6'), - Packet::Binary(data) => { - buffer.push('b'); - general_purpose::STANDARD.encode_string(data, &mut buffer); - } - Packet::BinaryV3(data) => { - buffer.push_str("b4"); - general_purpose::STANDARD.encode_string(data, &mut buffer); - } - }; - buffer - } -} -impl From for tokio_tungstenite::tungstenite::Utf8Bytes { - fn from(value: Packet) -> Self { - String::from(value).into() - } -} -/// Deserialize a [Packet] from a [String] according to the Engine.IO protocol -impl TryFrom for Packet { - type Error = Error; - fn try_from(value: Str) -> Result { - let packet_type = value - .as_bytes() - .first() - .ok_or(Error::InvalidPacketType(None))?; - let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; - let res = match packet_type { - b'1' => Packet::Close, - b'2' if is_upgrade => Packet::PingUpgrade, - b'2' => Packet::Ping, - b'3' if is_upgrade => Packet::PongUpgrade, - b'3' => Packet::Pong, - b'4' => Packet::Message(value.slice(1..)), - b'5' => Packet::Upgrade, - b'6' => Packet::Noop, - b'b' if value.as_bytes().get(1) == Some(&b'4') => Packet::BinaryV3( - general_purpose::STANDARD - .decode(value.slice(2..).as_bytes())? - .into(), - ), - b'b' => Packet::Binary( - general_purpose::STANDARD - .decode(value.slice(1..).as_bytes())? - .into(), - ), - c => Err(Error::InvalidPacketType(Some(*c as char)))?, - }; - Ok(res) - } -} -impl TryFrom for Packet { - type Error = Error; - fn try_from(value: tokio_tungstenite::tungstenite::Utf8Bytes) -> Result { - // SAFETY: The utf8 bytes are guaranteed to be valid utf8 - Packet::try_from(unsafe { Str::from_bytes_unchecked(value.into()) }) - } -} - -impl TryFrom for Packet { - type Error = Error; - fn try_from(value: String) -> Result { - Packet::try_from(Str::from(value)) - } -} - -/// An OpenPacket is used to initiate a connection -#[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] -#[serde(rename_all = "camelCase")] -pub struct OpenPacket { - sid: Sid, - upgrades: Vec, - ping_interval: u64, - ping_timeout: u64, - max_payload: u64, -} - -impl OpenPacket { - /// Create a new [OpenPacket] - /// If the current transport is polling, the server will always allow the client to upgrade to websocket - pub fn new(transport: TransportType, sid: Sid, config: &EngineIoConfig) -> Self { - let upgrades = if transport == TransportType::Polling { - vec!["websocket".to_string()] - } else { - vec![] - }; - OpenPacket { - sid, - upgrades, - ping_interval: config.ping_interval.as_millis() as u64, - ping_timeout: config.ping_timeout.as_millis() as u64, - max_payload: config.max_payload, - } - } -} - -#[cfg(test)] -mod tests { - use crate::config::EngineIoConfig; - - use super::*; - use std::{convert::TryInto, time::Duration}; - - #[test] - fn test_open_packet() { - let sid = Sid::new(); - let packet = Packet::Open(OpenPacket::new( - TransportType::Polling, - sid, - &EngineIoConfig::default(), - )); - let packet_str: String = packet.into(); - assert_eq!( - packet_str, - format!( - "0{{\"sid\":\"{sid}\",\"upgrades\":[\"websocket\"],\"pingInterval\":25000,\"pingTimeout\":20000,\"maxPayload\":100000}}" - ) - ); - } - - #[test] - fn test_message_packet() { - let packet = Packet::Message("hello".into()); - let packet_str: String = packet.into(); - assert_eq!(packet_str, "4hello"); - } - - #[test] - fn test_message_packet_deserialize() { - let packet_str = "4hello".to_string(); - let packet: Packet = packet_str.try_into().unwrap(); - assert_eq!(packet, Packet::Message("hello".into())); - } - - #[test] - fn test_binary_packet() { - let packet = Packet::Binary(vec![1, 2, 3].into()); - let packet_str: String = packet.into(); - assert_eq!(packet_str, "bAQID"); - } - - #[test] - fn test_binary_packet_deserialize() { - let packet_str = "bAQID".to_string(); - let packet: Packet = packet_str.try_into().unwrap(); - assert_eq!(packet, Packet::Binary(vec![1, 2, 3].into())); - } - - #[test] - fn test_binary_packet_v3() { - let packet = Packet::BinaryV3(vec![1, 2, 3].into()); - let packet_str: String = packet.into(); - assert_eq!(packet_str, "b4AQID"); - } - - #[test] - fn test_binary_packet_v3_deserialize() { - let packet_str = "b4AQID".to_string(); - let packet: Packet = packet_str.try_into().unwrap(); - assert_eq!(packet, Packet::BinaryV3(vec![1, 2, 3].into())); - } - - #[test] - fn test_packet_get_size_hint() { - // Max serialized packet - let open = OpenPacket::new( - TransportType::Polling, - Sid::new(), - &EngineIoConfig { - max_buffer_size: usize::MAX, - max_payload: u64::MAX, - ping_interval: Duration::MAX, - ping_timeout: Duration::MAX, - transports: TransportType::Polling as u8 | TransportType::Websocket as u8, - ..Default::default() - }, - ); - let size = serde_json::to_string(&open).unwrap().len(); - let packet = Packet::Open(open); - assert_eq!(packet.get_size_hint(false), size); - - let packet = Packet::Close; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Ping; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Pong; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::PingUpgrade; - assert_eq!(packet.get_size_hint(false), 6); - - let packet = Packet::PongUpgrade; - assert_eq!(packet.get_size_hint(false), 6); - - let packet = Packet::Message("hello".into()); - assert_eq!(packet.get_size_hint(false), 6); - - let packet = Packet::Upgrade; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Noop; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Binary(vec![1, 2, 3].into()); - assert_eq!(packet.get_size_hint(false), 4); - assert_eq!(packet.get_size_hint(true), 5); - - let packet = Packet::BinaryV3(vec![1, 2, 3].into()); - assert_eq!(packet.get_size_hint(false), 4); - assert_eq!(packet.get_size_hint(true), 6); - } -} diff --git a/crates/engineioxide/src/peekable.rs b/crates/engineioxide/src/peekable.rs index bc8ddb6b..87d2d74c 100644 --- a/crates/engineioxide/src/peekable.rs +++ b/crates/engineioxide/src/peekable.rs @@ -47,7 +47,7 @@ mod tests { #[tokio::test] async fn peek() { use super::PeekableReceiver; - use crate::packet::Packet; + use engineioxide_core::Packet; use tokio::sync::mpsc::channel; let (tx, rx) = channel(1); diff --git a/crates/engineioxide/src/service/mod.rs b/crates/engineioxide/src/service/mod.rs index 4bce8fe6..c9b5ab54 100644 --- a/crates/engineioxide/src/service/mod.rs +++ b/crates/engineioxide/src/service/mod.rs @@ -47,7 +47,7 @@ use crate::{ mod futures; mod parser; -pub use self::parser::{ProtocolVersion, TransportType}; +pub use self::parser::ProtocolVersion; use self::{futures::ResponseFuture, parser::dispatch_req}; /// A `Service` that handles engine.io requests as a middleware. diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index 5adf3594..3ab4af82 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -82,7 +82,7 @@ where #[derive(thiserror::Error, Debug)] pub enum ParseError { #[error("transport unknown")] - UnknownTransport(#[from] engineioxide_core::UnknownTransportError), + UnknownTransport, #[error("bad handshake method")] BadHandshakeMethod, #[error("transport mismatch")] @@ -90,6 +90,11 @@ pub enum ParseError { #[error("unsupported protocol version")] UnsupportedProtocolVersion, } +impl From for ParseError { + fn from(_: engineioxide_core::UnknownTransportError) -> Self { + ParseError::UnknownTransport + } +} /// Convert an error into an http response /// If it is a known error, return the appropriate http status code @@ -105,9 +110,7 @@ impl From for Response> { .unwrap() }; match err { - UnknownTransport(_) => { - conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}") - } + UnknownTransport => conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}"), BadHandshakeMethod => { conn_err_resp("{\"code\":\"2\",\"message\":\"Bad handshake method\"}") } @@ -188,8 +191,8 @@ impl RequestInfo { .split('&') .find(|s| s.starts_with("transport=")) .and_then(|s| s.split('=').nth(1)) - .ok_or(UnknownTransport) - .and_then(|t| t.parse())?; + .and_then(|t| t.parse().ok()) + .ok_or(UnknownTransport)?; if !config.allowed_transport(transport) { return Err(TransportMismatch); diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 56776426..5c833818 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -64,14 +64,10 @@ use std::{ }; use crate::{ - config::EngineIoConfig, - errors::Error, - packet::Packet, - peekable::PeekableReceiver, - service::{ProtocolVersion, TransportType}, + config::EngineIoConfig, errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, }; use bytes::Bytes; -use engineioxide_core::Str; +use engineioxide_core::{Packet, Str, TransportType}; use http::request::Parts; use smallvec::{SmallVec, smallvec}; use tokio::{ @@ -111,8 +107,9 @@ impl From<&Error> for Option { use Error::*; match err { WsTransport(_) | Io(_) => Some(DisconnectReason::TransportError), - BadPacket(_) | Base64(_) | StrUtf8(_) | PayloadTooLarge | InvalidPacketLength - | InvalidPacketType(_) => Some(DisconnectReason::PacketParsingError), + BadPacket(_) | StrUtf8(_) | PacketParse(_) => { + Some(DisconnectReason::PacketParsingError) + } HeartbeatTimeout => Some(DisconnectReason::HeartbeatTimeout), _ => None, } diff --git a/crates/engineioxide/src/transport/mod.rs b/crates/engineioxide/src/transport/mod.rs index 94d843a5..bbdfc589 100644 --- a/crates/engineioxide/src/transport/mod.rs +++ b/crates/engineioxide/src/transport/mod.rs @@ -1,4 +1,23 @@ //! All transports modules available in engineioxide +use engineioxide_core::{OpenPacket, Sid, TransportType}; + +use crate::config::EngineIoConfig; + pub mod polling; pub mod ws; + +fn make_open_packet(transport: TransportType, id: Sid, config: &EngineIoConfig) -> OpenPacket { + let upgrades = if transport == TransportType::Polling { + vec!["websocket".to_string()] + } else { + vec![] + }; + OpenPacket { + sid: id, + upgrades, + ping_timeout: config.ping_timeout.as_millis() as u64, + ping_interval: config.ping_interval.as_millis() as u64, + max_payload: config.max_payload, + } +} diff --git a/crates/engineioxide/src/transport/polling/mod.rs b/crates/engineioxide/src/transport/polling/mod.rs index 075503c7..8f6a5559 100644 --- a/crates/engineioxide/src/transport/polling/mod.rs +++ b/crates/engineioxide/src/transport/polling/mod.rs @@ -7,7 +7,7 @@ use http::{Request, Response, StatusCode}; use http_body::Body; use http_body_util::Full; -use engineioxide_core::Sid; +use engineioxide_core::{Packet, Sid, TransportType}; use crate::{ DisconnectReason, @@ -15,9 +15,8 @@ use crate::{ engine::EngineIo, errors::Error, handler::EngineIoHandler, - packet::{OpenPacket, Packet}, - service::{ProtocolVersion, TransportType}, - transport::polling::payload::Payload, + service::ProtocolVersion, + transport::{make_open_packet, polling::payload::Payload}, }; mod payload; @@ -62,7 +61,7 @@ where supports_binary, ); - let packet = OpenPacket::new(TransportType::Polling, socket.id, &engine.config); + let packet = make_open_packet(TransportType::Polling, socket.id, &engine.config); socket.spawn_heartbeat(engine.config.ping_interval, engine.config.ping_timeout); @@ -182,7 +181,7 @@ where #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] error parsing packet: {:?}", e); engine.close_session(sid, DisconnectReason::PacketParsingError); - return Err(e); + return Err(e.into()); } }?; } diff --git a/crates/engineioxide/src/transport/polling/payload/decoder.rs b/crates/engineioxide/src/transport/polling/payload/decoder.rs index 3ef454d3..536ce1c4 100644 --- a/crates/engineioxide/src/transport/polling/payload/decoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/decoder.rs @@ -5,11 +5,10 @@ //! - v3_decoder: Decodes the payload stream according to the [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) //! +use engineioxide_core::{Packet, PacketParseError}; use futures_core::Stream; use futures_util::StreamExt; -use http::StatusCode; -use crate::{errors::Error, packet::Packet}; use bytes::Buf; use http_body::Body; use http_body_util::BodyStream; @@ -42,7 +41,7 @@ impl Payload { /// Polls the body stream for data and adds it to the chunk list in the state /// Returns an error if the packet length exceeds the maximum allowed payload size -async fn poll_body(state: &mut Payload, max_payload: u64) -> Result<(), Error> +async fn poll_body(state: &mut Payload, max_payload: u64) -> Result<(), PacketParseError> where B: Body + Unpin, E: std::fmt::Debug, @@ -59,7 +58,7 @@ where Err(_e) => { #[cfg(feature = "tracing")] tracing::debug!("error reading body stream: {:?}", _e); - Err(Error::HttpErrorResponse(StatusCode::BAD_REQUEST)) + Err(PacketParseError::InvalidPacketPayload) } }?; if state.current_payload_size + (data.remaining() as u64) <= max_payload { @@ -67,11 +66,14 @@ where state.buffer.push(data); Ok(()) } else { - Err(Error::PayloadTooLarge) + Err(PacketParseError::PayloadTooLarge { max: max_payload }) } } -pub fn v4_decoder(body: B, max_payload: u64) -> impl Stream> +pub fn v4_decoder( + body: B, + max_payload: u64, +) -> impl Stream> where B: Body + Unpin, E: std::fmt::Debug, @@ -93,11 +95,14 @@ where } // Read from the buffer until the packet separator is found - if let Err(e) = (&mut state.buffer) + if let Err(_err) = (&mut state.buffer) .reader() .read_until(PACKET_SEPARATOR_V4, &mut packet_buf) { - break Some((Err(Error::Io(e)), state)); + #[cfg(feature = "tracing")] + tracing::debug!("failed to read packet payload: {_err}"); + + break Some((Err(PacketParseError::InvalidPacketPayload), state)); } let separator_found = packet_buf.ends_with(&[PACKET_SEPARATOR_V4]); @@ -111,7 +116,7 @@ where || (state.end_of_stream && state.buffer.remaining() == 0 && !packet_buf.is_empty()) { let packet = String::from_utf8(packet_buf) - .map_err(|_| Error::InvalidPacketLength) + .map_err(PacketParseError::from) .and_then(Packet::try_from); // Convert the packet buffer to a Packet object break Some((packet, state)); // Emit the packet and the updated state } else if state.end_of_stream && state.buffer.remaining() == 0 { @@ -125,7 +130,7 @@ where pub fn v3_binary_decoder( body: B, max_payload: u64, -) -> impl Stream> +) -> impl Stream> where B: Body + Unpin, E: std::fmt::Debug, @@ -156,11 +161,14 @@ where // If there is no packet_type found if packet_type.is_none() && state.buffer.remaining() > 0 { // Read from the buffer until the packet separator is found - if let Err(e) = (&mut state.buffer) + if let Err(_err) = (&mut state.buffer) .reader() .read_until(BINARY_PACKET_SEPARATOR_V3, &mut packet_buf) { - break Some((Err(Error::Io(e)), state)); + #[cfg(feature = "tracing")] + tracing::debug!("failed to read packet payload: {_err}"); + + break Some((Err(PacketParseError::InvalidPacketPayload), state)); } // Extract packet_type and packet_size @@ -173,11 +181,11 @@ where Some(&STRING_PACKET_IDENTIFIER_V3) => { packet_type = Some(STRING_PACKET_IDENTIFIER_V3) } - _ => break Some((Err(Error::InvalidPacketLength), state)), + _ => break Some((Err(PacketParseError::InvalidPacketLen), state)), } if packet_buf.len() > 9 { - break Some((Err(Error::InvalidPacketLength), state)); + break Some((Err(PacketParseError::InvalidPacketLen), state)); } let size_str = &packet_buf[1..] @@ -187,7 +195,7 @@ where if let Ok(size) = size_str.parse() { packet_size = size; } else { - break Some((Err(Error::InvalidPacketLength), state)); + break Some((Err(PacketParseError::InvalidPacketLen), state)); } packet_buf.clear(); } @@ -204,10 +212,10 @@ where // Read the packet data let packet = match packet_type.unwrap() { STRING_PACKET_IDENTIFIER_V3 => String::from_utf8(packet_buf) - .map_err(|_| Error::InvalidPacketLength) + .map_err(PacketParseError::from) .and_then(Packet::try_from), // Convert the packet buffer to a Packet object BINARY_PACKET_IDENTIFIER_V3 => Ok(Packet::BinaryV3(packet_buf.into())), - _ => Err(Error::InvalidPacketLength), + _ => Err(PacketParseError::InvalidPacketLen), }; break Some((packet, state)); @@ -222,7 +230,7 @@ where pub fn v3_string_decoder( body: impl Body + Unpin, max_payload: u64, -) -> impl Stream> { +) -> impl Stream> { use std::io::ErrorKind; use unicode_segmentation::UnicodeSegmentation; @@ -245,7 +253,7 @@ pub fn v3_string_decoder( if state.end_of_stream && state.buffer.remaining() == 0 && state.yield_packets > 0 { break None; // Reached end of stream with no more data, end the stream } else if state.end_of_stream && state.buffer.remaining() == 0 { - return Some((Err(Error::InvalidPacketLength), state)); + return Some((Err(PacketParseError::InvalidPacketLen), state)); } let mut reader = (&mut state.buffer).reader(); @@ -258,7 +266,12 @@ pub fn v3_string_decoder( let available = match reader.fill_buf() { Ok(n) => n, Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, - Err(e) => return Some((Err(Error::Io(e)), state)), + Err(_err) => { + #[cfg(feature = "tracing")] + tracing::debug!("failed to read packet payload: {_err}"); + + return Some((Err(PacketParseError::InvalidPacketPayload), state)); + } }; let old_len = packet_buf.len(); packet_buf.extend_from_slice(available); @@ -267,9 +280,10 @@ pub fn v3_string_decoder( Some(i) => { // Extract the packet length from the available data packet_graphemes_len = match std::str::from_utf8(&packet_buf[..i]) - .map_err(|_| Error::InvalidPacketLength) + .map_err(PacketParseError::from) .and_then(|s| { - s.parse::().map_err(|_| Error::InvalidPacketLength) + s.parse::() + .map_err(|_| PacketParseError::InvalidPacketLen) }) { Ok(size) => size, Err(e) => return Some((Err(e), state)), @@ -279,7 +293,7 @@ pub fn v3_string_decoder( (true, i + 1 - old_len) // Mark as done and set the used bytes count } None if state.end_of_stream && remaining - available.len() == 0 => { - return Some((Err(Error::InvalidPacketLength), state)); + return Some((Err(PacketParseError::InvalidPacketLen), state)); } // Reached end of stream and end of bufferered chunks without finding the separator None => (false, available.len()), // Continue reading more data } @@ -336,8 +350,9 @@ pub fn v3_string_decoder( if let Ok(packet) = std::str::from_utf8(&packet_buf) { if packet.graphemes(true).count() == packet_graphemes_len { // SAFETY: packet_buf is a valid utf8 string checkd above + let packet = unsafe { String::from_utf8_unchecked(packet_buf) }; - let packet = Packet::try_from(packet).map_err(|_| Error::InvalidPacketLength); + let packet = Packet::try_from(packet); state.yield_packets += 1; break Some((packet, state)); // Emit the packet and the updated state } @@ -356,8 +371,6 @@ mod tests { use http_body::Frame; use http_body_util::{Full, StreamBody}; - use crate::packet::Packet; - use super::*; const MAX_PAYLOAD: u64 = 100_000; @@ -423,7 +436,10 @@ mod tests { let payload = v4_decoder(stream, MAX_PAYLOAD); futures_util::pin_mut!(payload); let packet = payload.next().await.unwrap(); - assert!(matches!(packet, Err(Error::PayloadTooLarge))); + assert!(matches!( + packet, + Err(PacketParseError::PayloadTooLarge { max: MAX_PAYLOAD }) + )); } } @@ -551,7 +567,10 @@ mod tests { let payload = v3_binary_decoder(stream, MAX_PAYLOAD); futures_util::pin_mut!(payload); let packet = payload.next().await.unwrap(); - assert!(matches!(packet, Err(Error::PayloadTooLarge))); + assert!(matches!( + packet, + Err(PacketParseError::PayloadTooLarge { max: MAX_PAYLOAD }) + )); } for i in 1..DATA.len() { let stream = StreamBody::new(futures_util::stream::iter( @@ -562,7 +581,10 @@ mod tests { let payload = v3_string_decoder(stream, MAX_PAYLOAD); futures_util::pin_mut!(payload); let packet = payload.next().await.unwrap(); - assert!(matches!(packet, Err(Error::PayloadTooLarge))); + assert!(matches!( + packet, + Err(PacketParseError::PayloadTooLarge { max: MAX_PAYLOAD }) + )); } } } diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index b6c506a6..2115588e 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -10,9 +10,10 @@ use tokio::sync::MutexGuard; use crate::{ - errors::Error, packet::Packet, peekable::PeekableReceiver, socket::PacketBuf, + errors::Error, peekable::PeekableReceiver, socket::PacketBuf, transport::polling::payload::Payload, }; +use engineioxide_core::Packet; /// Try to immediately poll a new packet buf from the rx channel and check that the new packet can be added to the payload /// diff --git a/crates/engineioxide/src/transport/polling/payload/mod.rs b/crates/engineioxide/src/transport/polling/payload/mod.rs index 40b8f650..9dd2da8a 100644 --- a/crates/engineioxide/src/transport/polling/payload/mod.rs +++ b/crates/engineioxide/src/transport/polling/payload/mod.rs @@ -1,9 +1,10 @@ //! Payload encoder and decoder for polling transport. use crate::{ - errors::Error, packet::Packet, peekable::PeekableReceiver, service::ProtocolVersion, - socket::PacketBuf, + errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, socket::PacketBuf, }; +use engineioxide_core::{Packet, PacketParseError}; + use bytes::Bytes; use futures_core::Stream; use http::Request; @@ -27,7 +28,7 @@ pub fn decoder( body: Request + Unpin>, #[allow(unused_variables)] protocol: ProtocolVersion, max_payload: u64, -) -> impl Stream> { +) -> impl Stream> { #[cfg(feature = "v3")] { use futures_util::future::Either; diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 5c84d46f..924dd080 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -18,24 +18,17 @@ use tokio::{ use tokio_tungstenite::{ WebSocketStream, tungstenite::{ - Message, + self, Message, handshake::derive_accept_key, protocol::{Role, WebSocketConfig}, }, }; -use engineioxide_core::Sid; +use engineioxide_core::{Packet, Sid, Str, TransportType}; use crate::{ - DisconnectReason, Socket, - body::ResponseBody, - config::EngineIoConfig, - engine::EngineIo, - errors::Error, - handler::EngineIoHandler, - packet::{OpenPacket, Packet}, - service::ProtocolVersion, - service::TransportType, + DisconnectReason, Socket, body::ResponseBody, config::EngineIoConfig, engine::EngineIo, + errors::Error, handler::EngineIoHandler, service::ProtocolVersion, transport::make_open_packet, }; /// Create a response for websocket upgrade @@ -171,7 +164,7 @@ where { while let Some(msg) = rx.try_next().await? { match msg { - Message::Text(msg) => match Packet::try_from(msg)? { + Message::Text(msg) => match Packet::try_from(ws_bytes_to_str(msg))? { Packet::Close => { #[cfg(feature = "tracing")] tracing::debug!("[sid={}] closing session", socket.id); @@ -283,7 +276,8 @@ async fn init_handshake( where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let packet = Packet::Open(OpenPacket::new(TransportType::Websocket, sid, config)); + let packet = Packet::Open(make_open_packet(TransportType::Websocket, sid, config)); + let packet: String = packet.into(); ws.send(Message::Text(packet.into())).await?; Ok(()) } @@ -327,11 +321,12 @@ where Some(Ok(Message::Text(d))) => d, _ => Err(Error::Upgrade)?, }; - match Packet::try_from(msg)? { + match Packet::try_from(ws_bytes_to_str(msg))? { Packet::PingUpgrade => { socket.start_upgrade(); + let pong: String = Packet::PongUpgrade.into(); // Respond with a PongUpgrade packet - ws.send(Message::Text(Packet::PongUpgrade.into())).await?; + ws.send(Message::Text(pong.into())).await?; } p => Err(Error::BadPacket(p))?, }; @@ -353,7 +348,7 @@ where Err(Error::Upgrade)? } }; - match Packet::try_from(msg)? { + match Packet::try_from(ws_bytes_to_str(msg))? { Packet::Upgrade => { #[cfg(feature = "tracing")] tracing::debug!("ws upgraded successful") @@ -366,3 +361,9 @@ where socket.upgrade_to_websocket(); Ok(()) } + +fn ws_bytes_to_str(bytes: tungstenite::Utf8Bytes) -> Str { + // SAFETY: We are converting a valid UTF-8 byte slice + // to a string without checking its validity. + unsafe { Str::from_bytes_unchecked(bytes.into()) } +} From 7266fc8c90f129be8a415d19fe7fd0fdefb9ddae Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 20 Jul 2025 21:51:49 +0200 Subject: [PATCH 03/17] feat(engineio): refactor shared types to core --- Cargo.lock | 39 ++++++--- Cargo.toml | 2 +- crates/engineioxide-client/Cargo.toml | 17 +--- crates/engineioxide-client/src/client.rs | 1 + crates/engineioxide-client/src/io.rs | 1 + crates/engineioxide-client/src/lib.rs | 8 +- .../engineioxide-client/src/transport/mod.rs | 1 + .../src/transport/polling.rs | 33 ++++++++ crates/engineioxide-client/tests/handshake.rs | 27 ++++++ crates/engineioxide-core/Cargo.toml | 22 +++++ crates/engineioxide-core/src/lib.rs | 7 +- crates/engineioxide-core/src/packet.rs | 38 ++++++++- .../src}/payload/buf.rs | 0 .../src}/payload/decoder.rs | 9 +- .../src}/payload/encoder.rs | 82 +++++++++---------- .../src}/payload/mod.rs | 18 ++-- .../src/payload}/peekable.rs | 7 +- .../src/{transport.rs => protocol.rs} | 39 +++++++++ crates/engineioxide/Cargo.toml | 12 +-- crates/engineioxide/src/engine.rs | 3 +- crates/engineioxide/src/errors.rs | 21 ++--- crates/engineioxide/src/lib.rs | 6 +- crates/engineioxide/src/service/mod.rs | 3 +- crates/engineioxide/src/service/parser.rs | 38 +-------- crates/engineioxide/src/socket.rs | 21 ++--- .../transport/{polling/mod.rs => polling.rs} | 20 ++--- crates/engineioxide/src/transport/ws.rs | 6 +- 27 files changed, 297 insertions(+), 184 deletions(-) create mode 100644 crates/engineioxide-client/src/client.rs create mode 100644 crates/engineioxide-client/src/io.rs create mode 100644 crates/engineioxide-client/src/transport/mod.rs create mode 100644 crates/engineioxide-client/src/transport/polling.rs create mode 100644 crates/engineioxide-client/tests/handshake.rs rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/buf.rs (100%) rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/decoder.rs (99%) rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/encoder.rs (88%) rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/mod.rs (86%) rename crates/{engineioxide/src => engineioxide-core/src/payload}/peekable.rs (90%) rename crates/engineioxide-core/src/{transport.rs => protocol.rs} (60%) rename crates/engineioxide/src/transport/{polling/mod.rs => polling.rs} (93%) diff --git a/Cargo.lock b/Cargo.lock index a5c473cf..b7d9af9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,7 +646,6 @@ name = "engineioxide" version = "0.17.0" dependencies = [ "axum", - "base64 0.22.1", "bytes", "criterion", "engineioxide-core", @@ -657,8 +656,6 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "itoa", - "memchr", "pin-project-lite", "serde", "serde_json", @@ -672,17 +669,14 @@ dependencies = [ "tower-service", "tracing", "tracing-subscriber", - "unicode-segmentation", ] [[package]] name = "engineioxide-client" version = "0.17.0" dependencies = [ - "axum", - "base64 0.22.1", "bytes", - "criterion", + "engineioxide", "engineioxide-core", "futures-core", "futures-util", @@ -691,20 +685,15 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "itoa", - "memchr", "pin-project-lite", "serde", "serde_json", "smallvec", "thiserror 2.0.12", "tokio", - "tokio-stream", "tokio-tungstenite", - "tokio-util", "tracing", "tracing-subscriber", - "unicode-segmentation", ] [[package]] @@ -713,9 +702,19 @@ version = "0.2.0" dependencies = [ "base64 0.22.1", "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "memchr", "rand 0.9.1", "serde", "serde_json", + "smallvec", + "tokio", + "tracing", + "unicode-segmentation", ] [[package]] @@ -1069,6 +1068,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -2719,6 +2719,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.26.2" @@ -2855,6 +2861,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index ffe66ede..4767d5cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ bytes = { version = "1.10", features = ["serde"] } futures-core = "0.3" futures-util = { version = "0.3", default-features = false, features = ["std"] } tokio = "1.46" -tokio-tungstenite = "0.26" +tokio-tungstenite = { version = "0.26", default-features = false } serde = { version = "1.0", features = ["derive"] } smallvec = { version = "1.15", features = ["union"] } serde_json = "1.0" diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index 6d18b4dd..14fd4920 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -23,33 +23,22 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt", "time"] } -hyper.workspace = true +hyper = { workspace = true, features = ["client", "http1"] } tokio-tungstenite.workspace = true http-body-util.workspace = true pin-project-lite.workspace = true smallvec.workspace = true hyper-util = { workspace = true, features = ["tokio"] } -base64 = "0.22" - # Tracing tracing = { workspace = true, optional = true } -# Engine.io V3 payload -itoa = { workspace = true, optional = true } -memchr = { version = "2.7", optional = true } -unicode-segmentation = { version = "1.12", optional = true } - [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } tracing-subscriber.workspace = true -hyper = { workspace = true, features = ["server", "http1"] } -criterion.workspace = true -axum.workspace = true -tokio-stream.workspace = true -tokio-util.workspace = true +engineioxide = { path = "../engineioxide" } [features] -v3 = ["memchr", "unicode-segmentation", "itoa"] +v3 = ["engineioxide-core/v3"] tracing = ["dep:tracing"] __test_harness = [] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/engineioxide-client/src/client.rs @@ -0,0 +1 @@ + diff --git a/crates/engineioxide-client/src/io.rs b/crates/engineioxide-client/src/io.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/engineioxide-client/src/io.rs @@ -0,0 +1 @@ + diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 0689dcf1..2743c3c9 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -1,3 +1,9 @@ #![warn(clippy::pedantic)] - +#![allow(clippy::similar_names)] //! Engine.IO client library for Rust. + +mod client; +mod io; +mod transport; + +pub use crate::transport::polling::PollingClient; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs new file mode 100644 index 00000000..505916a0 --- /dev/null +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -0,0 +1 @@ +pub mod polling; diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs new file mode 100644 index 00000000..58e1d33e --- /dev/null +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -0,0 +1,33 @@ +use bytes::Bytes; +use engineioxide_core::Packet; +use http::Request; +use http_body_util::BodyExt; +use http_body_util::Full; +use hyper::service::Service as HyperSvc; + +pub struct PollingClient { + svc: S, +} +impl PollingClient +where + S: HyperSvc>>, + S::Response: hyper::body::Body, + ::Error: std::fmt::Debug, + ::Data: std::fmt::Debug, + S::Error: std::fmt::Debug, +{ + pub fn new(svc: S) -> Self { + Self { svc } + } + pub async fn handshake(&self) -> Packet { + let req = Request::builder() + .method("GET") + .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") + .body(Full::default()) + .unwrap(); + let res = self.svc.call(req).await; + let body = res.unwrap().collect().await.unwrap(); + let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap()); + packet.unwrap() + } +} diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs new file mode 100644 index 00000000..3ef72fcb --- /dev/null +++ b/crates/engineioxide-client/tests/handshake.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use bytes::Bytes; +use engineioxide::handler::EngineIoHandler; +use engineioxide::{DisconnectReason, service::EngineIoService}; +use engineioxide::{Socket, Str}; +use engineioxide_client::PollingClient; + +#[derive(Debug)] +struct Handler; +impl EngineIoHandler for Handler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) {} + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) {} + + fn on_message(self: &Arc, msg: Str, socket: Arc>) {} + + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) {} +} +#[tokio::test] +async fn handshake() { + let svc = EngineIoService::new(Arc::new(Handler)); + let client = PollingClient::new(svc); + let packet = client.handshake().await; + dbg!(packet); +} diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index 42daaf7a..33d2d6d8 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -18,3 +18,25 @@ base64 = "0.22" serde.workspace = true bytes.workspace = true serde_json.workspace = true +http-body.workspace = true +http-body-util.workspace = true +http.workspace = true +futures-util.workspace = true +tokio = { workspace = true, features = ["sync"] } +smallvec.workspace = true + +# Engine.io V3 payload +itoa = { workspace = true, optional = true } +memchr = { version = "2.7", optional = true } +unicode-segmentation = { version = "1.12", optional = true } + +# Tracing +tracing = { workspace = true, optional = true } + + +[features] +v3 = ["dep:memchr", "dep:unicode-segmentation", "dep:itoa"] +tracing = ["dep:tracing"] + +[dev-dependencies] +tokio = { workspace = true, features = ["sync", "macros", "rt"] } diff --git a/crates/engineioxide-core/src/lib.rs b/crates/engineioxide-core/src/lib.rs index 3149548c..9cd36e90 100644 --- a/crates/engineioxide-core/src/lib.rs +++ b/crates/engineioxide-core/src/lib.rs @@ -30,11 +30,12 @@ #![doc = include_str!("../README.md")] mod packet; +mod protocol; mod sid; mod str; -mod transport; -pub use packet::{OpenPacket, Packet, PacketParseError}; +pub use packet::{OpenPacket, Packet, PacketBuf, PacketParseError}; +pub use protocol::{ProtocolVersion, TransportType, UnknownTransportError}; pub use sid::Sid; pub use str::Str; -pub use transport::{TransportType, UnknownTransportError}; +pub mod payload; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 4ce60300..ec48c370 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -2,7 +2,8 @@ use std::fmt; use base64::{Engine, engine::general_purpose}; use bytes::Bytes; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use smallvec::SmallVec; use crate::{Sid, Str}; @@ -53,6 +54,8 @@ pub enum Packet { /// An error that occurs when parsing a packet. #[derive(Debug)] pub enum PacketParseError { + /// Invalid connect packet + InvalidConnectPacket(serde_json::Error), /// The packet type is invalid. InvalidPacketType(Option), /// The packet payload is invalid. @@ -72,6 +75,7 @@ pub enum PacketParseError { impl fmt::Display for PacketParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + PacketParseError::InvalidConnectPacket(e) => write!(f, "invalid connect packet: {e}"), PacketParseError::InvalidPacketType(c) => write!(f, "invalid packet type: {c:?}"), PacketParseError::InvalidPacketPayload => write!(f, "invalid packet payload"), PacketParseError::InvalidPacketLen => write!(f, "invalid packet length"), @@ -101,6 +105,11 @@ impl From for PacketParseError { PacketParseError::InvalidUtf8Boundary(err) } } +impl From for PacketParseError { + fn from(err: serde_json::Error) -> Self { + PacketParseError::InvalidConnectPacket(err) + } +} impl std::error::Error for PacketParseError {} impl Packet { @@ -204,6 +213,7 @@ impl TryFrom for Packet { .ok_or(PacketParseError::InvalidPacketType(None))?; let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; let res = match packet_type { + b'0' => Packet::Open(serde_json::from_str(value.slice(1..).as_str())?), b'1' => Packet::Close, b'2' if is_upgrade => Packet::PingUpgrade, b'2' => Packet::Ping, @@ -236,7 +246,7 @@ impl TryFrom for Packet { } /// An OpenPacket is used to initiate a connection -#[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, PartialOrd)] #[serde(rename_all = "camelCase")] pub struct OpenPacket { /// The session ID. @@ -265,6 +275,13 @@ impl Default for OpenPacket { } } +/// Buffered packets to send to the client. +/// It is used to ensure atomicity when sending multiple packets to the client. +/// +/// The [`PacketBuf`] stack size will impact the dynamically allocated buffer +/// of the internal mpsc channel. +pub type PacketBuf = SmallVec<[Packet; 2]>; + #[cfg(test)] mod tests { @@ -290,6 +307,23 @@ mod tests { ); } + #[test] + fn test_open_packet_deserialize() { + let sid = Sid::new(); + let ref_packet = OpenPacket { + sid, + upgrades: vec!["websocket".to_string()], + ping_interval: Duration::from_millis(25000).as_millis() as u64, + ping_timeout: Duration::from_millis(20000).as_millis() as u64, + max_payload: 100000, + }; + let packet_str = format!( + "0{{\"sid\":\"{sid}\",\"upgrades\":[\"websocket\"],\"pingInterval\":25000,\"pingTimeout\":20000,\"maxPayload\":100000}}" + ); + let packet = Packet::try_from(packet_str).unwrap(); + assert!(matches!(packet, Packet::Open(p) if p == ref_packet)); + } + #[test] fn test_message_packet() { let packet = Packet::Message("hello".into()); diff --git a/crates/engineioxide/src/transport/polling/payload/buf.rs b/crates/engineioxide-core/src/payload/buf.rs similarity index 100% rename from crates/engineioxide/src/transport/polling/payload/buf.rs rename to crates/engineioxide-core/src/payload/buf.rs diff --git a/crates/engineioxide/src/transport/polling/payload/decoder.rs b/crates/engineioxide-core/src/payload/decoder.rs similarity index 99% rename from crates/engineioxide/src/transport/polling/payload/decoder.rs rename to crates/engineioxide-core/src/payload/decoder.rs index 536ce1c4..555a94ba 100644 --- a/crates/engineioxide/src/transport/polling/payload/decoder.rs +++ b/crates/engineioxide-core/src/payload/decoder.rs @@ -5,9 +5,8 @@ //! - v3_decoder: Decodes the payload stream according to the [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) //! -use engineioxide_core::{Packet, PacketParseError}; -use futures_core::Stream; -use futures_util::StreamExt; +use crate::{Packet, PacketParseError}; +use futures_util::{Stream, StreamExt}; use bytes::Buf; use http_body::Body; @@ -137,7 +136,7 @@ where { use std::io::Read; - use crate::transport::polling::payload::{ + use crate::payload::{ BINARY_PACKET_IDENTIFIER_V3, BINARY_PACKET_SEPARATOR_V3, STRING_PACKET_IDENTIFIER_V3, }; @@ -234,7 +233,7 @@ pub fn v3_string_decoder( use std::io::ErrorKind; use unicode_segmentation::UnicodeSegmentation; - use crate::transport::polling::payload::STRING_PACKET_SEPARATOR_V3; + use crate::payload::STRING_PACKET_SEPARATOR_V3; #[cfg(feature = "tracing")] tracing::debug!("decoding payload with v3 string decoder"); diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs similarity index 88% rename from crates/engineioxide/src/transport/polling/payload/encoder.rs rename to crates/engineioxide-core/src/payload/encoder.rs index 2115588e..c0fae946 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -7,13 +7,14 @@ //! * binary encoder (used when there are binary packets and the client supports binary) //! +use smallvec::smallvec; use tokio::sync::MutexGuard; use crate::{ - errors::Error, peekable::PeekableReceiver, socket::PacketBuf, - transport::polling::payload::Payload, + Packet, + packet::PacketBuf, + payload::{Payload, peekable::PeekableReceiver}, }; -use engineioxide_core::Packet; /// Try to immediately poll a new packet buf from the rx channel and check that the new packet can be added to the payload /// @@ -56,10 +57,9 @@ fn try_recv_packet( /// Same as [`try_recv_packet`] /// but wait for a new packet if there is no packet in the buffer -async fn recv_packet( - rx: &mut MutexGuard<'_, PeekableReceiver>, -) -> Result { - let packet = rx.recv().await.ok_or(Error::Aborted)?; +async fn recv_packet(rx: &mut MutexGuard<'_, PeekableReceiver>) -> PacketBuf { + let packet = rx.recv().await.unwrap_or(smallvec![]); + if Some(&Packet::Close) == packet.first() { #[cfg(feature = "tracing")] tracing::debug!("Received close packet, closing channel"); @@ -68,7 +68,7 @@ async fn recv_packet( #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", packet); - Ok(packet) + packet } /// Encode multiple packets into a string payload according to the @@ -76,8 +76,8 @@ async fn recv_packet( pub async fn v4_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, -) -> Result { - use crate::transport::polling::payload::PACKET_SEPARATOR_V4; +) -> Payload { + use crate::payload::PACKET_SEPARATOR_V4; #[cfg(feature = "tracing")] tracing::debug!("encoding payload with v4 encoder"); @@ -100,21 +100,21 @@ pub async fn v4_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await?; + let packets = recv_packet(&mut rx).await; for packet in packets { let packet: String = packet.into(); data.push_str(&packet); } } - Ok(Payload::new(data.into(), false)) + Payload::new(data.into(), false) } /// Encode one packet into a *binary* payload according to the /// [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] -pub fn v3_bin_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> Result<(), Error> { - use crate::transport::polling::payload::BINARY_PACKET_SEPARATOR_V3; +pub fn v3_bin_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { + use crate::payload::BINARY_PACKET_SEPARATOR_V3; use bytes::BufMut; let mut itoa = itoa::Buffer::new(); @@ -148,14 +148,13 @@ pub fn v3_bin_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> Resu data.extend_from_slice(packet.as_bytes()); // packet } }; - Ok(()) } /// Encode one packet into a *string* payload according to the /// [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] -pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> Result<(), Error> { - use crate::transport::polling::payload::STRING_PACKET_SEPARATOR_V3; +pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { + use crate::payload::STRING_PACKET_SEPARATOR_V3; use bytes::BufMut; let packet: String = packet.into(); let packet = format!( @@ -165,7 +164,6 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> R packet ); data.put_slice(packet.as_bytes()); - Ok(()) } /// Encode multiple packet packet into a *string* payload if there is no binary packet or into a *binary* payload if there are binary packets @@ -174,7 +172,7 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> R pub async fn v3_binary_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, -) -> Result { +) -> Payload { let mut data = bytes::BytesMut::new(); let mut packet_buffer: Vec = Vec::new(); @@ -203,25 +201,25 @@ pub async fn v3_binary_encoder( if has_binary { for packet in packet_buffer { - v3_bin_packet_encoder(packet, &mut data)?; + v3_bin_packet_encoder(packet, &mut data); } } else { for packet in packet_buffer { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } } // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await?; + let packets = recv_packet(&mut rx).await; for packet in packets { match packet { Packet::BinaryV3(_) | Packet::Binary(_) => { - v3_bin_packet_encoder(packet, &mut data)?; + v3_bin_packet_encoder(packet, &mut data); has_binary = true; } packet => { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } }; } @@ -229,7 +227,7 @@ pub async fn v3_binary_encoder( #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", &data); - Ok(Payload::new(data.freeze(), has_binary)) + Payload::new(data.freeze(), has_binary) } /// Encode multiple packet packet into a *string* payload according to the @@ -238,7 +236,7 @@ pub async fn v3_binary_encoder( pub async fn v3_string_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, -) -> Result { +) -> Payload { let mut data = bytes::BytesMut::new(); #[cfg(feature = "tracing")] @@ -251,19 +249,19 @@ pub async fn v3_string_encoder( let current_size = data.len() + PUNCTUATION_LEN + max_packet_size_len; while let Some(packets) = try_recv_packet(&mut rx, current_size, max_payload, true) { for packet in packets { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } } // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await?; + let packets = recv_packet(&mut rx).await; for packet in packets { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } } - Ok(Payload::new(data.freeze(), false)) + Payload::new(data.freeze(), false) } #[cfg(test)] @@ -290,7 +288,7 @@ mod tests { .unwrap(); tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) .unwrap(); - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); } @@ -311,17 +309,17 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, "4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; assert_eq!(data, "bAQIDBA==\x1e4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; assert_eq!(data, "4hello€".as_bytes()); } } @@ -342,9 +340,7 @@ mod tests { .unwrap(); tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) .unwrap(); - let Payload { - data, has_binary, .. - } = v3_string_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, has_binary } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); assert!(!has_binary); } @@ -368,12 +364,12 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10).await.unwrap(); + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10).await; assert_eq!(data, "10:b4AQIDBA==7:4hello€7:4hello€".as_bytes()); } } @@ -394,9 +390,7 @@ mod tests { &[1, 2, 3, 4] ))]) .unwrap(); - let Payload { - data, has_binary, .. - } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, has_binary } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); assert!(has_binary); } @@ -424,12 +418,12 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); } { let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€7:4hello€".as_bytes()); } } diff --git a/crates/engineioxide/src/transport/polling/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs similarity index 86% rename from crates/engineioxide/src/transport/polling/payload/mod.rs rename to crates/engineioxide-core/src/payload/mod.rs index 9dd2da8a..bd6cc4cf 100644 --- a/crates/engineioxide/src/transport/polling/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -1,18 +1,18 @@ //! Payload encoder and decoder for polling transport. -use crate::{ - errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, socket::PacketBuf, -}; -use engineioxide_core::{Packet, PacketParseError}; +use crate::{Packet, PacketParseError, ProtocolVersion, packet::PacketBuf}; use bytes::Bytes; -use futures_core::Stream; +use futures_util::Stream; use http::Request; use tokio::sync::MutexGuard; mod buf; mod decoder; mod encoder; +mod peekable; + +pub use peekable::PeekableReceiver; const PACKET_SEPARATOR_V4: u8 = b'\x1e'; #[cfg(feature = "v3")] @@ -24,6 +24,7 @@ const STRING_PACKET_IDENTIFIER_V3: u8 = 0x00; #[cfg(feature = "v3")] const BINARY_PACKET_IDENTIFIER_V3: u8 = 0x01; +/// Decode a payload into a stream of packets. pub fn decoder( body: Request + Unpin>, #[allow(unused_variables)] protocol: ProtocolVersion, @@ -55,23 +56,26 @@ pub fn decoder( } /// A payload to transmit to the client through http polling -#[non_exhaustive] pub struct Payload { + /// The data of the payload. pub data: Bytes, + /// Whether the payload contains binary data. pub has_binary: bool, } impl Payload { + /// Creates a new payload with the given data and binary flag. pub fn new(data: Bytes, has_binary: bool) -> Self { Self { data, has_binary } } } +/// Encodes a payload into a byte stream. pub async fn encoder( rx: MutexGuard<'_, PeekableReceiver>, #[allow(unused_variables)] protocol: ProtocolVersion, #[cfg(feature = "v3")] supports_binary: bool, max_payload: u64, -) -> Result { +) -> Payload { #[cfg(feature = "v3")] { match protocol { diff --git a/crates/engineioxide/src/peekable.rs b/crates/engineioxide-core/src/payload/peekable.rs similarity index 90% rename from crates/engineioxide/src/peekable.rs rename to crates/engineioxide-core/src/payload/peekable.rs index 87d2d74c..d4761a57 100644 --- a/crates/engineioxide/src/peekable.rs +++ b/crates/engineioxide-core/src/payload/peekable.rs @@ -11,15 +11,18 @@ pub struct PeekableReceiver { next: Option, } impl PeekableReceiver { + /// Create a new peekable receiver pub fn new(rx: Receiver) -> Self { Self { rx, next: None } } + /// Peek the next packet without consuming it pub fn peek(&mut self) -> Option<&T> { if self.next.is_none() { self.next = self.rx.try_recv().ok(); } self.next.as_ref() } + /// Receive the next packet, consuming it pub async fn recv(&mut self) -> Option { if self.next.is_none() { self.rx.recv().await @@ -27,6 +30,7 @@ impl PeekableReceiver { self.next.take() } } + /// Try to receive the next packet without blocking pub fn try_recv(&mut self) -> Result { if self.next.is_none() { self.rx.try_recv() @@ -35,6 +39,7 @@ impl PeekableReceiver { } } + /// Close the receiver pub fn close(&mut self) { self.rx.close() } @@ -47,7 +52,7 @@ mod tests { #[tokio::test] async fn peek() { use super::PeekableReceiver; - use engineioxide_core::Packet; + use crate::Packet; use tokio::sync::mpsc::channel; let (tx, rx) = channel(1); diff --git a/crates/engineioxide-core/src/transport.rs b/crates/engineioxide-core/src/protocol.rs similarity index 60% rename from crates/engineioxide-core/src/transport.rs rename to crates/engineioxide-core/src/protocol.rs index 608b5e02..1a7474c6 100644 --- a/crates/engineioxide-core/src/transport.rs +++ b/crates/engineioxide-core/src/protocol.rs @@ -56,3 +56,42 @@ impl From for String { } } } + +#[derive(Debug)] +pub struct UnknownProtocolVersionError; +impl std::fmt::Display for UnknownProtocolVersionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown protocol version") + } +} +impl std::error::Error for UnknownProtocolVersionError {} + +/// The engine.io protocol version +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum ProtocolVersion { + /// The protocol version 3 + V3 = 3, + /// The protocol version 4 + V4 = 4, +} + +impl FromStr for ProtocolVersion { + type Err = UnknownProtocolVersionError; + + #[cfg(feature = "v3")] + fn from_str(s: &str) -> Result { + match s { + "3" => Ok(ProtocolVersion::V3), + "4" => Ok(ProtocolVersion::V4), + _ => Err(UnknownProtocolVersionError), + } + } + + #[cfg(not(feature = "v3"))] + fn from_str(s: &str) -> Result { + match s { + "4" => Ok(ProtocolVersion::V4), + _ => Err(UnknownProtocolVersionError), + } + } +} diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index 44165616..336616df 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -32,22 +32,15 @@ tokio = { workspace = true, features = ["rt", "time"] } tower-service.workspace = true tower-layer.workspace = true hyper.workspace = true -tokio-tungstenite.workspace = true +tokio-tungstenite = { workspace = true, features = ["handshake"] } http-body-util.workspace = true pin-project-lite.workspace = true smallvec.workspace = true hyper-util = { workspace = true, features = ["tokio"] } -base64 = "0.22" - # Tracing tracing = { workspace = true, optional = true } -# Engine.io V3 payload -itoa = { workspace = true, optional = true } -memchr = { version = "2.7", optional = true } -unicode-segmentation = { version = "1.12", optional = true } - [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } tracing-subscriber.workspace = true @@ -56,8 +49,9 @@ criterion.workspace = true axum.workspace = true tokio-stream.workspace = true tokio-util.workspace = true + [features] -v3 = ["memchr", "unicode-segmentation", "itoa"] +v3 = ["engineioxide-core/v3"] tracing = ["dep:tracing"] __test_harness = [] diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index e98daf1f..38e2e19d 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -3,13 +3,12 @@ use std::{ sync::{Arc, RwLock}, }; -use engineioxide_core::{Sid, TransportType}; +use engineioxide_core::{ProtocolVersion, Sid, TransportType}; use http::request::Parts; use crate::{ config::EngineIoConfig, handler::EngineIoHandler, - service::ProtocolVersion, socket::{DisconnectReason, Socket}, }; diff --git a/crates/engineioxide/src/errors.rs b/crates/engineioxide/src/errors.rs index fbb20136..38a6495e 100644 --- a/crates/engineioxide/src/errors.rs +++ b/crates/engineioxide/src/errors.rs @@ -11,10 +11,6 @@ pub use engineioxide_core::PacketParseError; pub enum Error { #[error("error decoding packet from request: {0}")] PacketParse(#[from] PacketParseError), - #[error("error decoding packet: {0}")] - StrUtf8(#[from] std::str::Utf8Error), - #[error("io error: {0}")] - Io(#[from] std::io::Error), #[error("bad packet received")] BadPacket(Packet), #[error("ws transport error: {0}")] @@ -29,11 +25,11 @@ pub enum Error { HeartbeatTimeout, #[error("upgrade error")] Upgrade, - #[error("aborted connection")] - Aborted, - #[error("http error response: {0}")] - HttpErrorResponse(StatusCode), + #[error("multiple http polling error")] + MultipleHttpPolling, + #[error("invalid websocket Sec-WebSocket-Key http header")] + InvalidWebSocketKey, #[error("unknown session id")] UnknownSessionID(Sid), @@ -54,15 +50,14 @@ impl From for Response> { .unwrap() }; match err { - Error::HttpErrorResponse(code) => Response::builder() - .status(code) - .body(ResponseBody::empty_response()) - .unwrap(), Error::PacketParse(PacketParseError::PayloadTooLarge { .. }) => Response::builder() .status(413) .body(ResponseBody::empty_response()) .unwrap(), - Error::BadPacket(_) | Error::PacketParse(_) => Response::builder() + Error::BadPacket(_) + | Error::PacketParse(_) + | Error::MultipleHttpPolling + | Error::InvalidWebSocketKey => Response::builder() .status(400) .body(ResponseBody::empty_response()) .unwrap(), diff --git a/crates/engineioxide/src/lib.rs b/crates/engineioxide/src/lib.rs index 68ad1628..b0c9f0ef 100644 --- a/crates/engineioxide/src/lib.rs +++ b/crates/engineioxide/src/lib.rs @@ -31,13 +31,12 @@ )] #![doc = include_str!("../Readme.md")] -pub use engineioxide_core::Str; -pub use service::ProtocolVersion; +pub use engineioxide_core::{ProtocolVersion, Str, TransportType}; pub use socket::{DisconnectReason, Socket}; #[doc(hidden)] #[cfg(feature = "__test_harness")] -pub use engineioxide_core::{OpenPacket, Packet, PacketParseError, TransportType}; +pub use engineioxide_core::{OpenPacket, Packet, PacketParseError}; pub mod config; pub mod handler; @@ -54,5 +53,4 @@ pub mod sid { mod body; mod engine; mod errors; -mod peekable; mod transport; diff --git a/crates/engineioxide/src/service/mod.rs b/crates/engineioxide/src/service/mod.rs index c9b5ab54..6b9d1c13 100644 --- a/crates/engineioxide/src/service/mod.rs +++ b/crates/engineioxide/src/service/mod.rs @@ -33,6 +33,8 @@ use std::{ }; use bytes::Bytes; +#[cfg(feature = "__test_harness")] +use engineioxide_core::ProtocolVersion; use futures_util::future::{self, Ready}; use http::{Request, Response}; use http_body::Body; @@ -47,7 +49,6 @@ use crate::{ mod futures; mod parser; -pub use self::parser::ProtocolVersion; use self::{futures::ResponseFuture, parser::dispatch_req}; /// A `Service` that handles engine.io requests as a middleware. diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index 3ab4af82..c015f5e2 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -1,10 +1,10 @@ //! A Parser module to parse any `EngineIo` query -use std::{future::Future, str::FromStr, sync::Arc}; +use std::{future::Future, sync::Arc}; use http::{Method, Request, Response}; -use engineioxide_core::{Sid, TransportType}; +use engineioxide_core::{ProtocolVersion, Sid, TransportType}; use crate::{ body::ResponseBody, @@ -122,36 +122,6 @@ impl From for Response> { } } -/// The engine.io protocol version -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum ProtocolVersion { - /// The protocol version 3 - V3 = 3, - /// The protocol version 4 - V4 = 4, -} - -impl FromStr for ProtocolVersion { - type Err = ParseError; - - #[cfg(feature = "v3")] - fn from_str(s: &str) -> Result { - match s { - "3" => Ok(ProtocolVersion::V3), - "4" => Ok(ProtocolVersion::V4), - _ => Err(ParseError::UnsupportedProtocolVersion), - } - } - - #[cfg(not(feature = "v3"))] - fn from_str(s: &str) -> Result { - match s { - "4" => Ok(ProtocolVersion::V4), - _ => Err(ParseError::UnsupportedProtocolVersion), - } - } -} - /// The request information extracted from the request URI. #[derive(Debug)] pub struct RequestInfo { @@ -178,8 +148,8 @@ impl RequestInfo { .split('&') .find(|s| s.starts_with("EIO=")) .and_then(|s| s.split('=').nth(1)) - .ok_or(UnsupportedProtocolVersion) - .and_then(|t| t.parse())?; + .and_then(|t| t.parse().ok()) + .ok_or(UnsupportedProtocolVersion)?; let sid = query .split('&') diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 5c833818..b33725a8 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -63,11 +63,11 @@ use std::{ time::Duration, }; -use crate::{ - config::EngineIoConfig, errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, -}; +use crate::{config::EngineIoConfig, errors::Error}; use bytes::Bytes; -use engineioxide_core::{Packet, Str, TransportType}; +use engineioxide_core::{ + Packet, PacketBuf, ProtocolVersion, Str, TransportType, payload::PeekableReceiver, +}; use http::request::Parts; use smallvec::{SmallVec, smallvec}; use tokio::{ @@ -106,10 +106,8 @@ impl From<&Error> for Option { fn from(err: &Error) -> Self { use Error::*; match err { - WsTransport(_) | Io(_) => Some(DisconnectReason::TransportError), - BadPacket(_) | StrUtf8(_) | PacketParse(_) => { - Some(DisconnectReason::PacketParsingError) - } + WsTransport(_) => Some(DisconnectReason::TransportError), + BadPacket(_) | PacketParse(_) => Some(DisconnectReason::PacketParsingError), HeartbeatTimeout => Some(DisconnectReason::HeartbeatTimeout), _ => None, } @@ -158,13 +156,6 @@ impl Permit<'_> { } } -/// Buffered packets to send to the client. -/// It is used to ensure atomicity when sending multiple packets to the client. -/// -/// The [`PacketBuf`] stack size will impact the dynamically allocated buffer -/// of the internal mpsc channel. -pub(crate) type PacketBuf = SmallVec<[Packet; 2]>; - /// A [`Socket`] represents a client connection to the server. /// It is agnostic to the [`TransportType`]. /// diff --git a/crates/engineioxide/src/transport/polling/mod.rs b/crates/engineioxide/src/transport/polling.rs similarity index 93% rename from crates/engineioxide/src/transport/polling/mod.rs rename to crates/engineioxide/src/transport/polling.rs index 8f6a5559..74196b07 100644 --- a/crates/engineioxide/src/transport/polling/mod.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -2,25 +2,19 @@ use std::sync::Arc; use bytes::Bytes; +use engineioxide_core::payload::{self, Payload}; use futures_util::StreamExt; use http::{Request, Response, StatusCode}; use http_body::Body; use http_body_util::Full; -use engineioxide_core::{Packet, Sid, TransportType}; +use engineioxide_core::{Packet, ProtocolVersion, Sid, TransportType}; use crate::{ - DisconnectReason, - body::ResponseBody, - engine::EngineIo, - errors::Error, - handler::EngineIoHandler, - service::ProtocolVersion, - transport::{make_open_packet, polling::payload::Payload}, + DisconnectReason, body::ResponseBody, engine::EngineIo, errors::Error, + handler::EngineIoHandler, transport::make_open_packet, }; -mod payload; - /// Create a response for http request fn http_response( code: StatusCode, @@ -107,7 +101,7 @@ where Ok(s) => s, Err(_) => { socket.close(DisconnectReason::MultipleHttpPollingError); - return Err(Error::HttpErrorResponse(StatusCode::BAD_REQUEST)); + return Err(Error::MultipleHttpPolling); } }; @@ -118,9 +112,9 @@ where #[cfg(feature = "v3")] let Payload { data, has_binary } = - payload::encoder(rx, protocol, socket.supports_binary, max_payload).await?; + payload::encoder(rx, protocol, socket.supports_binary, max_payload).await; #[cfg(not(feature = "v3"))] - let Payload { data, has_binary } = payload::encoder(rx, protocol, max_payload).await?; + let Payload { data, has_binary } = payload::encoder(rx, protocol, max_payload).await; #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] sending data: {:?}", data); diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 924dd080..9b69c72e 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -24,11 +24,11 @@ use tokio_tungstenite::{ }, }; -use engineioxide_core::{Packet, Sid, Str, TransportType}; +use engineioxide_core::{Packet, ProtocolVersion, Sid, Str, TransportType}; use crate::{ DisconnectReason, Socket, body::ResponseBody, config::EngineIoConfig, engine::EngineIo, - errors::Error, handler::EngineIoHandler, service::ProtocolVersion, transport::make_open_packet, + errors::Error, handler::EngineIoHandler, transport::make_open_packet, }; /// Create a response for websocket upgrade @@ -62,7 +62,7 @@ pub fn new_req( let ws_key = parts .headers .get("Sec-WebSocket-Key") - .ok_or(Error::HttpErrorResponse(StatusCode::BAD_REQUEST))? + .ok_or(Error::InvalidWebSocketKey)? .clone(); tokio::spawn(async move { From a7f23e0f5e44ba96515826b114d5fcf3f12d5b2b Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 26 Jul 2025 23:17:18 +0200 Subject: [PATCH 04/17] feat(client): wip --- crates/engineioxide-client/src/client.rs | 71 +++++++++++++++++++ crates/engineioxide-client/src/lib.rs | 3 +- .../src/transport/polling.rs | 24 +++++-- crates/engineioxide-client/tests/handshake.rs | 52 +++++++++++--- crates/engineioxide-core/src/packet.rs | 6 ++ 5 files changed, 139 insertions(+), 17 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 8b137891..f9ea2c1a 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -1 +1,72 @@ +use std::{ + pin::Pin, + sync::Mutex, + task::{Context, Poll}, +}; +use bytes::Bytes; +use engineioxide_core::{Packet, PacketBuf, Sid, Str, TransportType}; +use futures_core::Stream; +use http::Request; +use http_body_util::Full; +use hyper::service::Service as HyperSvc; +use smallvec::smallvec; +use tokio::sync::mpsc; + +use crate::HttpClient; + +pub struct Client { + pub transport: TransportType, + pub sid: Sid, + tx: mpsc::Sender, + pub(crate) rx: Mutex>, + inner: HttpClient, +} + +pub struct ClientStream {} + +impl Stream for ClientStream { + type Item = Packet; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } +} + +impl Client +where + S: HyperSvc>>, + S::Response: hyper::body::Body, + ::Error: std::fmt::Debug, + ::Data: std::fmt::Debug, + S::Error: std::fmt::Debug, +{ + pub async fn connect(svc: S) -> Result<(Self, ClientStream), ()> { + let (tx, rx) = mpsc::channel(255); + let inner = HttpClient::new(svc); + let packet = inner.handshake().await.unwrap(); + + let client = Client { + transport: TransportType::Polling, + sid: packet.sid, + tx, + rx: Mutex::new(rx), + inner, + }; + + let stream = ClientStream {}; + + Ok((client, stream)) + } + + pub fn emit(&self, msg: Str) { + self.tx.try_send(smallvec![Packet::Message(msg)]).unwrap(); + } + pub fn emit_binary(&self, data: Bytes) { + self.tx.try_send(smallvec![Packet::Binary(data)]).unwrap(); + } + + pub fn connected(&self) -> bool { + self.rx.try_lock().is_ok() + } +} diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 2743c3c9..469cca32 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -5,5 +5,4 @@ mod client; mod io; mod transport; - -pub use crate::transport::polling::PollingClient; +pub use crate::transport::polling::HttpClient; diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 58e1d33e..2cf7ee7b 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,14 +1,18 @@ use bytes::Bytes; +use engineioxide_core::OpenPacket; use engineioxide_core::Packet; +use engineioxide_core::PacketParseError; +use engineioxide_core::Sid; use http::Request; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; -pub struct PollingClient { +pub struct HttpClient { svc: S, } -impl PollingClient + +impl HttpClient where S: HyperSvc>>, S::Response: hyper::body::Body, @@ -19,7 +23,7 @@ where pub fn new(svc: S) -> Self { Self { svc } } - pub async fn handshake(&self) -> Packet { + pub async fn handshake(&self) -> Result { let req = Request::builder() .method("GET") .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") @@ -27,7 +31,17 @@ where .unwrap(); let res = self.svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); - let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap()); - packet.unwrap() + let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap())?; + match packet { + Packet::Open(open) => Ok(open), + _ => Err(PacketParseError::InvalidPacketType(Some('1'))), + } + } + + pub async fn post(&self, id: Sid, packet: impl Into) -> Result<(), S::Error> { + let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); + let req = Request::post(uri).body(Full::from(packet.into())).unwrap(); + self.svc.call(req).await?; + Ok(()) } } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 3ef72fcb..c3372bc6 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,24 +4,56 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::PollingClient; +use engineioxide_client::HttpClient; +use engineioxide_core::{Packet, Sid}; +use futures_util::TryFutureExt; +use tokio::sync::mpsc; + +#[derive(Debug, PartialEq, Eq)] +enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), + Binary(Sid, Bytes), +} #[derive(Debug)] -struct Handler; +struct Handler { + tx: mpsc::Sender, +} + +impl Handler { + fn new() -> (Self, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(100); + (Self { tx }, rx) + } +} + impl EngineIoHandler for Handler { type Data = (); - fn on_connect(self: Arc, socket: Arc>) {} + fn on_connect(self: Arc, socket: Arc>) { + self.tx.try_send(Event::Connect(socket.id)).unwrap(); + } - fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) {} + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx + .try_send(Event::Disconnect(socket.id, reason)) + .unwrap(); + } - fn on_message(self: &Arc, msg: Str, socket: Arc>) {} + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx.try_send(Event::Message(socket.id, msg)).unwrap(); + } - fn on_binary(self: &Arc, data: Bytes, socket: Arc>) {} + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { + self.tx.try_send(Event::Binary(socket.id, data)).unwrap(); + } } + #[tokio::test] async fn handshake() { - let svc = EngineIoService::new(Arc::new(Handler)); - let client = PollingClient::new(svc); - let packet = client.handshake().await; - dbg!(packet); + let (handler, mut rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + let packet = HttpClient::new(svc).handshake().await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(packet.sid)); } diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index ec48c370..6cb1950d 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -169,6 +169,12 @@ impl Packet { } } +impl From for Bytes { + fn from(value: Packet) -> Self { + String::from(value).into() + } +} + /// Serialize a [Packet] to a [String] according to the Engine.IO protocol impl From for String { fn from(packet: Packet) -> String { From 63011e0aab67da14e875dc60ecec656c58b4bbc6 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 27 Jul 2025 19:21:53 +0200 Subject: [PATCH 05/17] feat(client): wip --- Cargo.lock | 1 + crates/engineioxide-client/src/client.rs | 75 +++++++----- crates/engineioxide-client/src/lib.rs | 10 ++ .../engineioxide-client/src/transport/mod.rs | 19 +++ .../src/transport/polling.rs | 108 +++++++++++++++++- crates/engineioxide-core/Cargo.toml | 1 + crates/engineioxide-core/src/payload/mod.rs | 11 +- 7 files changed, 187 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7d9af9b..4b7cc7c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -708,6 +708,7 @@ dependencies = [ "http-body-util", "itoa", "memchr", + "pin-project-lite", "rand 0.9.1", "serde", "serde_json", diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index f9ea2c1a..f1ff8d77 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -5,31 +5,25 @@ use std::{ }; use bytes::Bytes; -use engineioxide_core::{Packet, PacketBuf, Sid, Str, TransportType}; +use engineioxide_core::{PacketBuf, Sid, TransportType}; use futures_core::Stream; +use futures_util::Sink; use http::Request; use http_body_util::Full; use hyper::service::Service as HyperSvc; -use smallvec::smallvec; -use tokio::sync::mpsc; +use tokio::sync::mpsc::{self, error::TrySendError}; -use crate::HttpClient; +use crate::{HttpClient, transport::Transport}; -pub struct Client { - pub transport: TransportType, - pub sid: Sid, - tx: mpsc::Sender, - pub(crate) rx: Mutex>, - inner: HttpClient, -} - -pub struct ClientStream {} - -impl Stream for ClientStream { - type Item = Packet; +pin_project_lite::pin_project! { + pub struct Client { + pub transport: Transport, + pub sid: Sid, + pub tx: mpsc::Sender, + pub(crate) rx: Mutex>, - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Poll::Pending + #[pin] + rrx: mpsc::Receiver, } } @@ -41,32 +35,55 @@ where ::Data: std::fmt::Debug, S::Error: std::fmt::Debug, { - pub async fn connect(svc: S) -> Result<(Self, ClientStream), ()> { + pub async fn connect(svc: S) -> Result { let (tx, rx) = mpsc::channel(255); let inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); + let (rtx, rrx) = mpsc::channel(100); let client = Client { - transport: TransportType::Polling, + transport: Transport::Polling(inner), sid: packet.sid, tx, rx: Mutex::new(rx), - inner, + rrx, }; - let stream = ClientStream {}; + Ok(client) + } + + pub fn connected(&self) -> bool { + self.rx.try_lock().is_ok() + } + + pub fn transport_type(&self) -> TransportType { + self.transport.transport_type() + } +} + +impl Sink for Client { + type Error = TrySendError; - Ok((client, stream)) + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) } - pub fn emit(&self, msg: Str) { - self.tx.try_send(smallvec![Packet::Message(msg)]).unwrap(); + fn start_send(self: Pin<&mut Self>, packets: PacketBuf) -> Result<(), Self::Error> { + self.tx.try_send(packets) } - pub fn emit_binary(&self, data: Bytes) { - self.tx.try_send(smallvec![Packet::Binary(data)]).unwrap(); + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) } - pub fn connected(&self) -> bool { - self.rx.try_lock().is_ok() + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl Stream for Client { + type Item = PacketBuf; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.rrx.poll_recv(cx) } } diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 469cca32..9c252391 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -6,3 +6,13 @@ mod client; mod io; mod transport; pub use crate::transport::polling::HttpClient; + +#[macro_export] +macro_rules! poll { + ($expr:expr) => { + match $expr { + std::task::Poll::Pending => return std::task::Poll::Pending, + std::task::Poll::Ready(value) => value, + } + }; +} diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 505916a0..ba3a866b 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1 +1,20 @@ +use engineioxide_core::TransportType; +use tokio_tungstenite::WebSocketStream; + +use crate::HttpClient; + pub mod polling; + +pub enum Transport { + Polling(HttpClient), + Websocket(), +} + +impl Transport { + pub fn transport_type(&self) -> TransportType { + match self { + Transport::Polling(_) => TransportType::Polling, + Transport::Websocket() => TransportType::Websocket, + } + } +} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 2cf7ee7b..c4ed3c79 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,15 +1,48 @@ +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; + use bytes::Bytes; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; +use engineioxide_core::PacketBuf; use engineioxide_core::PacketParseError; +use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; +use engineioxide_core::payload; +use futures_core::Stream; +use futures_util::FutureExt; +use futures_util::Sink; +use futures_util::StreamExt; +use futures_util::TryStreamExt; use http::Request; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; -pub struct HttpClient { - svc: S, +use crate::poll; + +pin_project_lite::pin_project! { + #[project = PollStateProj] + enum PollState { + No, + Pending { + #[pin] + fut: F + }, + Decoding { + stream: Pin>>> + } + } +} +pin_project_lite::pin_project! { + pub struct HttpClient + where + S: HyperSvc>>, + { + svc: S, + poll_state: PollState, + } } impl HttpClient @@ -21,7 +54,10 @@ where S::Error: std::fmt::Debug, { pub fn new(svc: S) -> Self { - Self { svc } + Self { + svc, + poll_state: PollState::No, + } } pub async fn handshake(&self) -> Result { let req = Request::builder() @@ -45,3 +81,69 @@ where Ok(()) } } + +impl Sink for HttpClient { + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} + + fn start_send(self: Pin<&mut Self>, item: PacketBuf) -> Result<(), Self::Error> {} + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} +} + +impl Stream for HttpClient +where + S: HyperSvc>>, + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().poll_state { + PollState::No => { + let id = Sid::new(); + let uri = + format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); + let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); + let fut = self.svc.call(req); + self.poll_state = PollState::Pending { fut }; + Poll::Pending + } + PollState::Pending { ref mut fut } => { + match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + Ok(body) => { + let body = Box::pin(body); + let stream = + payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); + self.poll_state = PollState::Decoding { stream }; + Poll::Pending + } + Err(err) => todo!(), + } + } + PollState::Decoding { ref mut stream } => match poll!(stream.poll_next_unpin(cx)) { + Some(packet) => Poll::Ready(Some(packet)), + None => { + self.poll_state = PollState::No; + cx.waker().wake_by_ref(); + Poll::Pending + } + }, + } + } +} + +impl PollState { + fn get_decoding(self) -> Pin>>> { + match self { + PollState::Decoding { stream } => stream, + _ => unreachable!(), + } + } +} diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index 33d2d6d8..e9c23bde 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -24,6 +24,7 @@ http.workspace = true futures-util.workspace = true tokio = { workspace = true, features = ["sync"] } smallvec.workspace = true +pin-project-lite.workspace = true # Engine.io V3 payload itoa = { workspace = true, optional = true } diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index bd6cc4cf..b029d3bb 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -4,7 +4,7 @@ use crate::{Packet, PacketParseError, ProtocolVersion, packet::PacketBuf}; use bytes::Bytes; use futures_util::Stream; -use http::Request; +use http::HeaderValue; use tokio::sync::MutexGuard; mod buf; @@ -26,18 +26,17 @@ const BINARY_PACKET_IDENTIFIER_V3: u8 = 0x01; /// Decode a payload into a stream of packets. pub fn decoder( - body: Request + Unpin>, + body: impl http_body::Body + Unpin, + content_type: Option<&HeaderValue>, #[allow(unused_variables)] protocol: ProtocolVersion, max_payload: u64, ) -> impl Stream> { #[cfg(feature = "v3")] { use futures_util::future::Either; - use http::header::CONTENT_TYPE; #[cfg(feature = "tracing")] - tracing::debug!("decoding payload {:?}", body.headers().get(CONTENT_TYPE)); - let is_binary = - body.headers().get(CONTENT_TYPE) == Some(&"application/octet-stream".parse().unwrap()); + tracing::debug!("decoding payload {:?}", content_type); + let is_binary = content_type == Some(&"application/octet-stream".parse().unwrap()); match protocol { ProtocolVersion::V4 => Either::Left(decoder::v4_decoder(body, max_payload)), ProtocolVersion::V3 if is_binary => { From 60d27881863557c0152ce8c6770bd05a6dd422d5 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 3 Aug 2025 20:19:20 +0200 Subject: [PATCH 06/17] feat(client): wip --- crates/engineioxide-client/src/client.rs | 40 ++--- crates/engineioxide-client/src/lib.rs | 2 +- .../engineioxide-client/src/transport/mod.rs | 48 ++++-- .../src/transport/polling.rs | 142 ++++++++++++++---- 4 files changed, 172 insertions(+), 60 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index f1ff8d77..ab1a597b 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,32 +4,28 @@ use std::{ task::{Context, Poll}, }; -use bytes::Bytes; -use engineioxide_core::{PacketBuf, Sid, TransportType}; +use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; -use http::Request; -use http_body_util::Full; -use hyper::service::Service as HyperSvc; use tokio::sync::mpsc::{self, error::TrySendError}; -use crate::{HttpClient, transport::Transport}; +use crate::{ + HttpClient, + transport::{Transport, polling::PollingSvc}, +}; pin_project_lite::pin_project! { - pub struct Client { + pub struct Client { + #[pin] pub transport: Transport, pub sid: Sid, pub tx: mpsc::Sender, pub(crate) rx: Mutex>, - - #[pin] - rrx: mpsc::Receiver, } } -impl Client +impl Client where - S: HyperSvc>>, S::Response: hyper::body::Body, ::Error: std::fmt::Debug, ::Data: std::fmt::Debug, @@ -40,13 +36,11 @@ where let inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); - let (rtx, rrx) = mpsc::channel(100); let client = Client { - transport: Transport::Polling(inner), + transport: Transport::Polling { inner }, sid: packet.sid, tx, rx: Mutex::new(rx), - rrx, }; Ok(client) @@ -61,7 +55,7 @@ where } } -impl Sink for Client { +impl Sink for Client { type Error = TrySendError; fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { @@ -81,9 +75,15 @@ impl Sink for Client { } } -impl Stream for Client { - type Item = PacketBuf; - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.rrx.poll_recv(cx) +impl Stream for Client +where + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Item = Result; + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport.poll_next(cx) } } diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 9c252391..7cca1e2b 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -1,4 +1,4 @@ -#![warn(clippy::pedantic)] +// #![warn(clippy::pedantic)] #![allow(clippy::similar_names)] //! Engine.IO client library for Rust. diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index ba3a866b..24a03bfb 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1,20 +1,50 @@ -use engineioxide_core::TransportType; -use tokio_tungstenite::WebSocketStream; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; -use crate::HttpClient; +use engineioxide_core::{Packet, PacketParseError, TransportType}; +use futures_core::Stream; + +use crate::{HttpClient, transport::polling::PollingSvc}; pub mod polling; -pub enum Transport { - Polling(HttpClient), - Websocket(), +pin_project_lite::pin_project! { + #[project = TransportProj] + pub enum Transport { + Polling { + #[pin] + inner: HttpClient + }, + Websocket { + #[pin] + inner: HttpClient + } + } } -impl Transport { +impl Transport { pub fn transport_type(&self) -> TransportType { match self { - Transport::Polling(_) => TransportType::Polling, - Transport::Websocket() => TransportType::Websocket, + Transport::Polling { .. } => TransportType::Polling, + Transport::Websocket { .. } => TransportType::Websocket, + } + } +} + +impl Stream for Transport +where + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Item = Result; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().project() { + TransportProj::Polling { inner } => inner.poll_next(cx), + TransportProj::Websocket { inner } => inner.poll_next(cx), } } } diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index c4ed3c79..a856a232 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -3,28 +3,32 @@ use std::task::Context; use std::task::Poll; use bytes::Bytes; +use bytes::BytesMut; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; -use engineioxide_core::PacketBuf; use engineioxide_core::PacketParseError; use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; use engineioxide_core::payload; use futures_core::Stream; -use futures_util::FutureExt; use futures_util::Sink; use futures_util::StreamExt; -use futures_util::TryStreamExt; use http::Request; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; +use pin_project_lite::pin_project; use crate::poll; -pin_project_lite::pin_project! { +pub trait PollingSvc: HyperSvc>> {} +impl>>> PollingSvc for S {} + +pin_project! { #[project = PollStateProj] + #[derive(Default)] enum PollState { + #[default] No, Pending { #[pin] @@ -35,19 +39,40 @@ pin_project_lite::pin_project! { } } } -pin_project_lite::pin_project! { - pub struct HttpClient - where - S: HyperSvc>>, + +pin_project! { + #[project = PostStateProj] + enum PostState { + Encoding { + body: BytesMut + }, + Pending { + #[pin] + fut: F + } + } +} + +impl Default for PostState { + fn default() -> Self { + PostState::Encoding { + body: BytesMut::default(), + } + } +} + +pin_project! { + pub struct HttpClient { svc: S, poll_state: PollState, + post_state: PostState, + sid: Option, } } -impl HttpClient +impl HttpClient where - S: HyperSvc>>, S::Response: hyper::body::Body, ::Error: std::fmt::Debug, ::Data: std::fmt::Debug, @@ -56,9 +81,12 @@ where pub fn new(svc: S) -> Self { Self { svc, - poll_state: PollState::No, + poll_state: PollState::default(), + post_state: PostState::default(), + sid: None, } } + pub async fn handshake(&self) -> Result { let req = Request::builder() .method("GET") @@ -82,21 +110,8 @@ where } } -impl Sink for HttpClient { - type Error = (); - - fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} - - fn start_send(self: Pin<&mut Self>, item: PacketBuf) -> Result<(), Self::Error> {} - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} -} - -impl Stream for HttpClient +impl Stream for HttpClient where - S: HyperSvc>>, S::Response: hyper::body::Body + 'static, ::Error: std::fmt::Debug + 'static, ::Data: Send + std::fmt::Debug + 'static, @@ -119,26 +134,93 @@ where match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { Ok(body) => { let body = Box::pin(body); + //TODO: implement limited body let stream = payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); self.poll_state = PollState::Decoding { stream }; Poll::Pending } - Err(err) => todo!(), + Err(err) => { + tracing::debug!(?err, "got body error"); + Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) + } } } - PollState::Decoding { ref mut stream } => match poll!(stream.poll_next_unpin(cx)) { - Some(packet) => Poll::Ready(Some(packet)), - None => { + PollState::Decoding { ref mut stream } => { + if let Some(packet) = poll!(stream.poll_next_unpin(cx)) { + Poll::Ready(Some(packet)) + } else { self.poll_state = PollState::No; + // Should not be needed. cx.waker().wake_by_ref(); Poll::Pending } - }, + } } } } +impl Sink for HttpClient +where + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + match self.post_state { + PostState::Encoding { .. } => Poll::Ready(Ok(())), + _ => Poll::Pending, + } + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + let body = match &self.post_state { + PostState::Encoding { body } => body, + _ => panic!( + "unexpected state, Sink::poll_ready should always be called before Sink::start_send" + ), + }; + //TODO: write packet + + Ok(()) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.post_state { + PostState::Encoding { ref body } => { + let req = Request::post( + "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", + ) + .body(Full::new(body.clone().freeze())) //TODO: fix cloning + .unwrap(); + let fut = self.svc.call(req); + self.post_state = PostState::Pending { fut }; + cx.waker().wake_by_ref(); + Poll::Pending + } + PostState::Pending { ref mut fut } => { + match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + Ok(res) => { + self.post_state = PostState::default(); + Poll::Ready(Ok(())) // TODO: check response == ok + } + Err(err) => { + self.post_state = PostState::default(); + todo!("handle error") + } + } + } + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + impl PollState { fn get_decoding(self) -> Pin>>> { match self { From 9dd59885ebdbb26e6c16f3b8d91d1e2a9147938a Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 3 Aug 2025 20:33:17 +0200 Subject: [PATCH 07/17] fix(engineio): add content type to decoder --- crates/engineioxide/src/transport/polling.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index 74196b07..732f157d 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use bytes::Bytes; use engineioxide_core::payload::{self, Payload}; use futures_util::StreamExt; -use http::{Request, Response, StatusCode}; +use http::{Request, Response, StatusCode, header::CONTENT_TYPE}; use http_body::Body; use http_body_util::Full; @@ -128,7 +128,7 @@ pub async fn post_req( engine: Arc>, protocol: ProtocolVersion, sid: Sid, - body: Request, + req: Request, ) -> Result>, Error> where H: EngineIoHandler, @@ -142,7 +142,9 @@ where return Err(Error::TransportMismatch); } - let packets = payload::decoder(body, protocol, engine.config.max_payload); + let (parts, body) = req.into_parts(); + let content_type = parts.headers.get(CONTENT_TYPE); + let packets = payload::decoder(body, content_type, protocol, engine.config.max_payload); futures_util::pin_mut!(packets); while let Some(packet) = packets.next().await { From 24ea9c51208cd529f081cc01d3908562b9c3c4b0 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 3 Aug 2025 22:01:45 +0200 Subject: [PATCH 08/17] feat(client): wip --- crates/engineioxide-client/Cargo.toml | 6 +- crates/engineioxide-client/src/client.rs | 73 +++++++++--- crates/engineioxide-client/src/lib.rs | 1 + .../engineioxide-client/src/transport/mod.rs | 39 ++++++- .../src/transport/polling.rs | 108 +++++++++++++----- crates/engineioxide-client/tests/handshake.rs | 30 ++++- 6 files changed, 200 insertions(+), 57 deletions(-) diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index 14fd4920..2d7b7fcb 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -35,10 +35,10 @@ tracing = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } -tracing-subscriber.workspace = true -engineioxide = { path = "../engineioxide" } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +engineioxide = { path = "../engineioxide", features = ["tracing", "v3"] } [features] v3 = ["engineioxide-core/v3"] -tracing = ["dep:tracing"] +tracing = ["dep:tracing", "engineioxide-core/tracing"] __test_harness = [] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index ab1a597b..74606e1f 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -1,23 +1,32 @@ use std::{ + fmt, pin::Pin, sync::Mutex, task::{Context, Poll}, }; -use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid, TransportType}; +use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid}; use futures_core::Stream; -use futures_util::Sink; +use futures_util::{ + Sink, StreamExt, + stream::{SplitSink, SplitStream}, +}; use tokio::sync::mpsc::{self, error::TrySendError}; use crate::{ - HttpClient, + HttpClient, poll, transport::{Transport, polling::PollingSvc}, }; pin_project_lite::pin_project! { pub struct Client { #[pin] - pub transport: Transport, + pub transport_rx: SplitStream>, + // TODO: is this the right implementation? We need something that can be driven itself. + // Otherwise we need a way to drive the transport_tx. Normally it should be driven by the user. + // But what if we need to send a PONG packet from the inner lib? + #[pin] + pub transport_tx: SplitSink, Packet>, pub sid: Sid, pub tx: mpsc::Sender, pub(crate) rx: Mutex>, @@ -26,18 +35,19 @@ pin_project_lite::pin_project! { impl Client where - S::Response: hyper::body::Body, - ::Error: std::fmt::Debug, - ::Data: std::fmt::Debug, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { pub async fn connect(svc: S) -> Result { let (tx, rx) = mpsc::channel(255); - let inner = HttpClient::new(svc); + let mut inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); + let transport = Transport::Polling { inner }; + let (transport_tx, transport_rx) = transport.split(); let client = Client { - transport: Transport::Polling { inner }, + transport_tx, + transport_rx, sid: packet.sid, tx, rx: Mutex::new(rx), @@ -49,10 +59,6 @@ where pub fn connected(&self) -> bool { self.rx.try_lock().is_ok() } - - pub fn transport_type(&self) -> TransportType { - self.transport.transport_type() - } } impl Sink for Client { @@ -77,13 +83,42 @@ impl Sink for Client { impl Stream for Client where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { type Item = Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport.poll_next(cx) + let project = self.project(); + match poll!(project.transport_rx.poll_next(cx)) { + Some(Ok(Packet::Ping)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + packet => Poll::Ready(packet), + } + } +} + +impl Sink for Client +where + S::Error: fmt::Debug, + ::Error: fmt::Debug, +{ + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport_tx.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + self.project().transport_tx.start_send(item) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport_tx.poll_flush(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport_tx.poll_close(cx) } } diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 7cca1e2b..fb39e455 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -5,6 +5,7 @@ mod client; mod io; mod transport; +pub use crate::client::Client; pub use crate::transport::polling::HttpClient; #[macro_export] diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 24a03bfb..d7bd1b1f 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1,10 +1,12 @@ use std::{ + fmt, pin::Pin, task::{Context, Poll}, }; use engineioxide_core::{Packet, PacketParseError, TransportType}; use futures_core::Stream; +use futures_util::Sink; use crate::{HttpClient, transport::polling::PollingSvc}; @@ -35,10 +37,8 @@ impl Transport { impl Stream for Transport where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -48,3 +48,34 @@ where } } } +impl Sink for Transport { + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_ready(cx), + TransportProj::Websocket { inner } => inner.poll_ready(cx), + } + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + match self.project() { + TransportProj::Polling { inner } => inner.start_send(item), + TransportProj::Websocket { inner } => inner.start_send(item), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_flush(cx), + TransportProj::Websocket { inner } => inner.poll_flush(cx), + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_close(cx), + TransportProj::Websocket { inner } => inner.poll_close(cx), + } + } +} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index a856a232..145db955 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::pin::Pin; use std::task::Context; use std::task::Poll; @@ -14,6 +15,8 @@ use futures_core::Stream; use futures_util::Sink; use futures_util::StreamExt; use http::Request; +use http::Response; +use http::StatusCode; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; @@ -21,8 +24,20 @@ use pin_project_lite::pin_project; use crate::poll; -pub trait PollingSvc: HyperSvc>> {} -impl>>> PollingSvc for S {} +pub trait PollingSvc: HyperSvc>, Response = Response> { + type Body: hyper::body::Body + 'static; +} + +impl PollingSvc for S +where + S: HyperSvc>, Response = Response>, + >>>::Error: fmt::Debug, + B: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, +{ + type Body = B; +} pin_project! { #[project = PollStateProj] @@ -73,10 +88,8 @@ pin_project! { impl HttpClient where - S::Response: hyper::body::Body, - ::Error: std::fmt::Debug, - ::Data: std::fmt::Debug, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { pub fn new(svc: S) -> Self { Self { @@ -87,7 +100,10 @@ where } } - pub async fn handshake(&self) -> Result { + pub async fn handshake(&mut self) -> Result { + #[cfg(feature = "tracing")] + tracing::trace!(?self, "handshake request"); + let req = Request::builder() .method("GET") .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") @@ -96,51 +112,55 @@ where let res = self.svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap())?; + match packet { - Packet::Open(open) => Ok(open), + Packet::Open(open) => { + self.sid = Some(open.sid); + Ok(open) + } _ => Err(PacketParseError::InvalidPacketType(Some('1'))), } } - - pub async fn post(&self, id: Sid, packet: impl Into) -> Result<(), S::Error> { - let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); - let req = Request::post(uri).body(Full::from(packet.into())).unwrap(); - self.svc.call(req).await?; - Ok(()) - } } impl Stream for HttpClient where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + #[cfg(feature = "tracing")] + tracing::trace!(poll_state = ?self.poll_state, "polling"); + match self.as_mut().poll_state { PollState::No => { - let id = Sid::new(); + let id = self.sid.unwrap(); let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); let fut = self.svc.call(req); self.poll_state = PollState::Pending { fut }; + cx.waker().wake_by_ref(); Poll::Pending } PollState::Pending { ref mut fut } => { match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { - Ok(body) => { + Ok(res) => { + let (parts, body) = res.into_parts(); + dbg!(&parts); + assert!(parts.status == StatusCode::OK); let body = Box::pin(body); - //TODO: implement limited body + //TODO: implement limited body + Content-Type let stream = payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); self.poll_state = PollState::Decoding { stream }; + cx.waker().wake_by_ref(); Poll::Pending } Err(err) => { + #[cfg(feature = "tracing")] tracing::debug!(?err, "got body error"); Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) } @@ -160,13 +180,7 @@ where } } -impl Sink for HttpClient -where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, -{ +impl Sink for HttpClient { type Error = (); fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { @@ -177,6 +191,9 @@ where } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + #[cfg(feature = "tracing")] + tracing::trace!(post_state = ?self.post_state, "sending packet"); + let body = match &self.post_state { PostState::Encoding { body } => body, _ => panic!( @@ -189,6 +206,9 @@ where } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + #[cfg(feature = "tracing")] + tracing::trace!(post_state = ?self.post_state, "flushing"); + match self.post_state { PostState::Encoding { ref body } => { let req = Request::post( @@ -229,3 +249,33 @@ impl PollState { } } } + +impl fmt::Debug for PollState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::No => write!(f, "No"), + Self::Pending { .. } => write!(f, "Pending"), + Self::Decoding { .. } => write!(f, "Decoding"), + } + } +} +impl fmt::Debug for PostState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Encoding { body } => f + .debug_struct("Encoding") + .field("body_len", &body.len()) + .finish(), + Self::Pending { .. } => write!(f, "Pending"), + } + } +} +impl fmt::Debug for HttpClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HttpClient") + .field("poll_state", &self.poll_state) + .field("post_state", &self.post_state) + .field("sid", &self.sid) + .finish() + } +} diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index c3372bc6..63b42e29 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,13 +1,15 @@ +use std::str::FromStr; use std::sync::Arc; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::HttpClient; +use engineioxide_client::{Client, HttpClient}; use engineioxide_core::{Packet, Sid}; -use futures_util::TryFutureExt; +use futures_util::{StreamExt, TryFutureExt}; use tokio::sync::mpsc; +use tracing_subscriber::EnvFilter; #[derive(Debug, PartialEq, Eq)] enum Event { @@ -52,8 +54,32 @@ impl EngineIoHandler for Handler { #[tokio::test] async fn handshake() { + tracing_subscriber::fmt::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init(); let (handler, mut rx) = Handler::new(); let svc = EngineIoService::new(Arc::new(handler)); let packet = HttpClient::new(svc).handshake().await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(packet.sid)); } + +#[tokio::test] +async fn connect() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); + let (handler, mut rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + let client = Client::connect(svc).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); + let (ctx, mut crx) = client.split(); + while let Some(event) = crx.next().await { + match event { + Ok(event) => { + dbg!(event); + } + Err(e) => panic!("Error: {e}"), + } + } +} From 1fac3aa092da431aadb7a6e061baa3e08abaf0c5 Mon Sep 17 00:00:00 2001 From: totodore Date: Fri, 10 Oct 2025 23:55:41 +0200 Subject: [PATCH 09/17] wip --- Cargo.lock | 2 +- crates/engineioxide-client/src/client.rs | 52 ++++++---------- .../src/transport/polling.rs | 62 ++++++++++++------- crates/engineioxide-client/tests/handshake.rs | 42 ++++++++++++- crates/engineioxide/src/transport/ws.rs | 3 +- 5 files changed, 102 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc0421c0..3bb9a4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -695,7 +695,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.15", "tokio", "tokio-tungstenite", "tracing", diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 74606e1f..ea6f4223 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -8,7 +8,7 @@ use std::{ use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid}; use futures_core::Stream; use futures_util::{ - Sink, StreamExt, + Sink, SinkExt, StreamExt, stream::{SplitSink, SplitStream}, }; use tokio::sync::mpsc::{self, error::TrySendError}; @@ -18,6 +18,13 @@ use crate::{ transport::{Transport, polling::PollingSvc}, }; +type SendPongFut = Pin< + Box< + dyn Future, Packet> as Sink>::Error>> + + 'static, + >, +>; + pin_project_lite::pin_project! { pub struct Client { #[pin] @@ -27,9 +34,10 @@ pin_project_lite::pin_project! { // But what if we need to send a PONG packet from the inner lib? #[pin] pub transport_tx: SplitSink, Packet>, + pub sid: Sid, - pub tx: mpsc::Sender, - pub(crate) rx: Mutex>, + // pub tx: mpsc::Sender, + // pub(crate) rx: Mutex>, } } @@ -39,7 +47,6 @@ where ::Error: fmt::Debug, { pub async fn connect(svc: S) -> Result { - let (tx, rx) = mpsc::channel(255); let mut inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); @@ -49,36 +56,10 @@ where transport_tx, transport_rx, sid: packet.sid, - tx, - rx: Mutex::new(rx), }; Ok(client) } - - pub fn connected(&self) -> bool { - self.rx.try_lock().is_ok() - } -} - -impl Sink for Client { - type Error = TrySendError; - - fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn start_send(self: Pin<&mut Self>, packets: PacketBuf) -> Result<(), Self::Error> { - self.tx.try_send(packets) - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } } impl Stream for Client @@ -88,10 +69,17 @@ where { type Item = Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let project = self.project(); - match poll!(project.transport_rx.poll_next(cx)) { + let mut this = self.project(); + match poll!(this.transport_rx.poll_next(cx)) { Some(Ok(Packet::Ping)) => { cx.waker().wake_by_ref(); + // let mut tx = self.transport_tx.clone(); + // let fut = async move { + // tx.send(Packet::Pong).await?; + // tx.flush().await + // }; + // this.pending_pong.set(Some(Box::pin(fut))); + Poll::Pending } packet => Poll::Ready(packet), diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 145db955..31e1f692 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::fmt; use std::pin::Pin; use std::task::Context; @@ -58,6 +59,10 @@ pin_project! { pin_project! { #[project = PostStateProj] enum PostState { + /// Ideally the queue should gradually encode packets in an async fashion + Queuing { + queue: VecDeque + }, Encoding { body: BytesMut }, @@ -80,7 +85,9 @@ pin_project! { pub struct HttpClient { svc: S, + #[pin] poll_state: PollState, + #[pin] post_state: PostState, sid: Option, } @@ -109,6 +116,7 @@ where .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") .body(Full::default()) .unwrap(); + let res = self.svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap())?; @@ -134,19 +142,20 @@ where #[cfg(feature = "tracing")] tracing::trace!(poll_state = ?self.poll_state, "polling"); - match self.as_mut().poll_state { - PollState::No => { + let mut poll_state_proj = self.as_mut().project().poll_state.project(); + match poll_state_proj { + PollStateProj::No => { let id = self.sid.unwrap(); let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); let fut = self.svc.call(req); - self.poll_state = PollState::Pending { fut }; + self.project().poll_state.set(PollState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } - PollState::Pending { ref mut fut } => { - match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + PollStateProj::Pending { ref mut fut } => { + match poll!(fut.as_mut().poll(cx)) { Ok(res) => { let (parts, body) = res.into_parts(); dbg!(&parts); @@ -155,7 +164,11 @@ where //TODO: implement limited body + Content-Type let stream = payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); - self.poll_state = PollState::Decoding { stream }; + + self.project() + .poll_state + .set(PollState::Decoding { stream }); + cx.waker().wake_by_ref(); Poll::Pending } @@ -166,11 +179,12 @@ where } } } - PollState::Decoding { ref mut stream } => { + PollStateProj::Decoding { ref mut stream } => { if let Some(packet) = poll!(stream.poll_next_unpin(cx)) { + dbg!(&packet); Poll::Ready(Some(packet)) } else { - self.poll_state = PollState::No; + self.project().poll_state.set(PollState::No); // Should not be needed. cx.waker().wake_by_ref(); Poll::Pending @@ -194,12 +208,8 @@ impl Sink for HttpClient { #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "sending packet"); - let body = match &self.post_state { - PostState::Encoding { body } => body, - _ => panic!( - "unexpected state, Sink::poll_ready should always be called before Sink::start_send" - ), - }; + self.project().post_state.set(PostState::Encoding { body }); + //TODO: write packet Ok(()) @@ -209,26 +219,34 @@ impl Sink for HttpClient { #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "flushing"); - match self.post_state { - PostState::Encoding { ref body } => { + match self.as_mut().project().post_state.project() { + PostStateProj::Queuing { queue } => { + // payload::encoder(rx, protocol, supports_binary, max_payload) + if let Some(packet) = packet { + self.project().post_state.set(PostState::Encoding { body }); + } + Poll::Ready(Ok(())) + } + PostStateProj::Encoding { body } => { let req = Request::post( "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", ) - .body(Full::new(body.clone().freeze())) //TODO: fix cloning + .body(Full::new(body.clone())) //TODO: fix cloning .unwrap(); let fut = self.svc.call(req); - self.post_state = PostState::Pending { fut }; + self.project().post_state.set(PostState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } - PostState::Pending { ref mut fut } => { - match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + PostStateProj::Pending { fut } => { + match poll!(fut.poll(cx)) { Ok(res) => { - self.post_state = PostState::default(); + assert!(res.status().is_success()); + self.project().post_state.set(PostState::default()); Poll::Ready(Ok(())) // TODO: check response == ok } Err(err) => { - self.post_state = PostState::default(); + self.project().post_state.set(PostState::default()); todo!("handle error") } } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 63b42e29..4ab7a104 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,5 +1,6 @@ use std::str::FromStr; use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; @@ -7,7 +8,7 @@ use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; use engineioxide_client::{Client, HttpClient}; use engineioxide_core::{Packet, Sid}; -use futures_util::{StreamExt, TryFutureExt}; +use futures_util::{SinkExt, StreamExt, TryFutureExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -56,7 +57,8 @@ impl EngineIoHandler for Handler { async fn handshake() { tracing_subscriber::fmt::fmt() .with_env_filter(EnvFilter::from_default_env()) - .try_init(); + .try_init() + .ok(); let (handler, mut rx) = Handler::new(); let svc = EngineIoService::new(Arc::new(handler)); let packet = HttpClient::new(svc).handshake().await.unwrap(); @@ -73,7 +75,8 @@ async fn connect() { let svc = EngineIoService::new(Arc::new(handler)); let client = Client::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (ctx, mut crx) = client.split(); + let (ctx, mut crx) = client.split::(); + while let Some(event) = crx.next().await { match event { Ok(event) => { @@ -83,3 +86,36 @@ async fn connect() { } } } + +#[tokio::test] +async fn spaaam() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); + let (handler, mut rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + let client = Client::connect(svc).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); + let (mut ctx, mut crx) = client.split::(); + + tokio::task::LocalSet::new() + .run_until(async move { + tokio::task::spawn_local(async move { + loop { + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + while let Some(event) = crx.next().await { + match event { + Ok(event) => { + dbg!(event); + } + Err(e) => panic!("Error: {e}"), + } + } + }) + .await; +} diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 2992f7bf..38156037 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -337,7 +337,8 @@ where #[cfg(feature = "tracing")] tracing::debug!("received first ping upgrade"); // Respond with a PongUpgrade packet - ws.send(Message::Text(pong.into())).await?; + ws.send(Message::Text(String::from(Packet::PongUpgrade).into())) + .await?; } p => Err(Error::BadPacket(p))?, }; From 4a4439f753a177e2a71610ec99969f8a9ce4b4c6 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Oct 2025 23:23:56 +0200 Subject: [PATCH 10/17] wip --- .../src/transport/polling.rs | 65 +++--- crates/engineioxide-client/tests/handshake.rs | 2 +- crates/engineioxide-core/Cargo.toml | 3 +- .../engineioxide-core/src/payload/encoder.rs | 196 ++++++++---------- crates/engineioxide-core/src/payload/mod.rs | 10 +- .../engineioxide-core/src/payload/peekable.rs | 84 -------- crates/engineioxide/src/socket.rs | 10 +- crates/engineioxide/src/transport/polling.rs | 37 +++- 8 files changed, 164 insertions(+), 243 deletions(-) delete mode 100644 crates/engineioxide-core/src/payload/peekable.rs diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 31e1f692..a365988a 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -5,16 +5,18 @@ use std::task::Context; use std::task::Poll; use bytes::Bytes; -use bytes::BytesMut; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; +use engineioxide_core::PacketBuf; use engineioxide_core::PacketParseError; use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; use engineioxide_core::payload; +use engineioxide_core::payload::Payload; use futures_core::Stream; use futures_util::Sink; use futures_util::StreamExt; +use futures_util::stream; use http::Request; use http::Response; use http::StatusCode; @@ -22,6 +24,7 @@ use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; +use smallvec::smallvec; use crate::poll; @@ -59,12 +62,13 @@ pin_project! { pin_project! { #[project = PostStateProj] enum PostState { - /// Ideally the queue should gradually encode packets in an async fashion + /// TODO: Ideally the queue should gradually encode packets in an async fashion Queuing { - queue: VecDeque + queue: VecDeque }, Encoding { - body: BytesMut + #[pin] + fut: Pin>>, }, Pending { #[pin] @@ -75,8 +79,8 @@ pin_project! { impl Default for PostState { fn default() -> Self { - PostState::Encoding { - body: BytesMut::default(), + PostState::Queuing { + queue: VecDeque::new(), } } } @@ -85,10 +89,13 @@ pin_project! { pub struct HttpClient { svc: S, + #[pin] poll_state: PollState, + #[pin] post_state: PostState, + sid: Option, } } @@ -199,7 +206,7 @@ impl Sink for HttpClient { fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { match self.post_state { - PostState::Encoding { .. } => Poll::Ready(Ok(())), + PostState::Queuing { .. } => Poll::Ready(Ok(())), _ => Poll::Pending, } } @@ -208,9 +215,10 @@ impl Sink for HttpClient { #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "sending packet"); - self.project().post_state.set(PostState::Encoding { body }); - - //TODO: write packet + match self.project().post_state.project() { + PostStateProj::Queuing { queue } => queue.push_back(smallvec![item]), + _ => panic!("unexpected post state"), + } Ok(()) } @@ -220,18 +228,26 @@ impl Sink for HttpClient { tracing::trace!(post_state = ?self.post_state, "flushing"); match self.as_mut().project().post_state.project() { + PostStateProj::Queuing { queue } if queue.is_empty() => Poll::Ready(Ok(())), PostStateProj::Queuing { queue } => { - // payload::encoder(rx, protocol, supports_binary, max_payload) - if let Some(packet) = packet { - self.project().post_state.set(PostState::Encoding { body }); - } - Poll::Ready(Ok(())) + let fut = payload::encoder( + stream::iter(queue.clone()), + ProtocolVersion::V4, + true, + 102400000, + ); + self.project() + .post_state + .set(PostState::Encoding { fut: Box::pin(fut) }); + cx.waker().wake_by_ref(); + Poll::Pending } - PostStateProj::Encoding { body } => { + PostStateProj::Encoding { fut } => { + let body = poll!(fut.poll(cx)); let req = Request::post( "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", ) - .body(Full::new(body.clone())) //TODO: fix cloning + .body(Full::new(body.data)) //TODO: fix cloning .unwrap(); let fut = self.svc.call(req); self.project().post_state.set(PostState::Pending { fut }); @@ -259,15 +275,6 @@ impl Sink for HttpClient { } } -impl PollState { - fn get_decoding(self) -> Pin>>> { - match self { - PollState::Decoding { stream } => stream, - _ => unreachable!(), - } - } -} - impl fmt::Debug for PollState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -280,11 +287,9 @@ impl fmt::Debug for PollState { impl fmt::Debug for PostState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Encoding { body } => f - .debug_struct("Encoding") - .field("body_len", &body.len()) - .finish(), + Self::Encoding { .. } => f.debug_struct("Encoding").finish(), Self::Pending { .. } => write!(f, "Pending"), + Self::Queuing { .. } => write!(f, "Queuing"), } } } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 4ab7a104..70d9d50b 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -103,7 +103,7 @@ async fn spaaam() { .run_until(async move { tokio::task::spawn_local(async move { loop { - ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Pong).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; } }); diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index e9c23bde..ea146aff 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -22,7 +22,6 @@ http-body.workspace = true http-body-util.workspace = true http.workspace = true futures-util.workspace = true -tokio = { workspace = true, features = ["sync"] } smallvec.workspace = true pin-project-lite.workspace = true @@ -40,4 +39,4 @@ v3 = ["dep:memchr", "dep:unicode-segmentation", "dep:itoa"] tracing = ["dep:tracing"] [dev-dependencies] -tokio = { workspace = true, features = ["sync", "macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index c0fae946..30de4190 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -7,14 +7,12 @@ //! * binary encoder (used when there are binary packets and the client supports binary) //! +use std::pin::Pin; + +use futures_util::{FutureExt, Stream, StreamExt, stream::Peekable}; use smallvec::smallvec; -use tokio::sync::MutexGuard; -use crate::{ - Packet, - packet::PacketBuf, - payload::{Payload, peekable::PeekableReceiver}, -}; +use crate::{Packet, packet::PacketBuf, payload::Payload}; /// Try to immediately poll a new packet buf from the rx channel and check that the new packet can be added to the payload /// @@ -27,12 +25,12 @@ use crate::{ /// * `max_payload` - The maximum payload length /// * `b64` - If binary packets should be encoded in base64 fn try_recv_packet( - rx: &mut MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, payload_len: usize, max_payload: u64, b64: bool, ) -> Option { - if let Some(packets) = rx.peek() { + if let Some(packets) = rx.as_mut().peek().now_or_never().flatten() { let size = packets.iter().map(|p| p.get_size_hint(b64)).sum::(); if (payload_len + size) as u64 > max_payload { #[cfg(feature = "tracing")] @@ -41,14 +39,14 @@ fn try_recv_packet( } } - let packets = rx.try_recv().ok(); + let packets = rx.next().now_or_never().flatten(); - if Some(&Packet::Close) == packets.as_ref().and_then(|p| p.first()) { - #[cfg(feature = "tracing")] - tracing::debug!("Received close packet, closing channel"); - rx.try_recv().ok(); - rx.close(); - } + // if Some(&Packet::Close) == packets.as_ref().and_then(|p| p.first()) { + // #[cfg(feature = "tracing")] + // tracing::debug!("Received close packet, closing channel"); + // rx.try_recv().ok(); + // rx.close(); + // } #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", packets); @@ -57,13 +55,14 @@ fn try_recv_packet( /// Same as [`try_recv_packet`] /// but wait for a new packet if there is no packet in the buffer -async fn recv_packet(rx: &mut MutexGuard<'_, PeekableReceiver>) -> PacketBuf { - let packet = rx.recv().await.unwrap_or(smallvec![]); +async fn recv_packet(mut rx: Pin<&mut Peekable>>) -> PacketBuf { + let packet = rx.next().await.unwrap_or(smallvec![]); if Some(&Packet::Close) == packet.first() { #[cfg(feature = "tracing")] tracing::debug!("Received close packet, closing channel"); - rx.close(); + + // rx.close(); } #[cfg(feature = "tracing")] @@ -74,7 +73,7 @@ async fn recv_packet(rx: &mut MutexGuard<'_, PeekableReceiver>) -> Pa /// Encode multiple packets into a string payload according to the /// [engine.io v4 protocol](https://socket.io/fr/docs/v4/engine-io-protocol/#http-long-polling-1) pub async fn v4_encoder( - mut rx: MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, max_payload: u64, ) -> Payload { use crate::payload::PACKET_SEPARATOR_V4; @@ -86,7 +85,7 @@ pub async fn v4_encoder( // Send all packets in the buffer const PUNCTUATION_LEN: usize = 1; while let Some(packets) = - try_recv_packet(&mut rx, data.len() + PUNCTUATION_LEN, max_payload, true) + try_recv_packet(rx.as_mut(), data.len() + PUNCTUATION_LEN, max_payload, true) { for packet in packets { let packet: String = packet.into(); @@ -100,7 +99,7 @@ pub async fn v4_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await; + let packets = recv_packet(rx.as_mut()).await; for packet in packets { let packet: String = packet.into(); data.push_str(&packet); @@ -170,7 +169,7 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { /// according to the [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] pub async fn v3_binary_encoder( - mut rx: MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, max_payload: u64, ) -> Payload { let mut data = bytes::BytesMut::new(); @@ -186,7 +185,7 @@ pub async fn v3_binary_encoder( // buffer all packets to find if there is binary packets let mut has_binary = false; - while let Some(packets) = try_recv_packet(&mut rx, estimated_size, max_payload, false) { + while let Some(packets) = try_recv_packet(rx.as_mut(), estimated_size, max_payload, false) { for packet in packets { if packet.is_binary() { has_binary = true; @@ -211,7 +210,7 @@ pub async fn v3_binary_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await; + let packets = recv_packet(rx.as_mut()).await; for packet in packets { match packet { Packet::BinaryV3(_) | Packet::Binary(_) => { @@ -234,7 +233,7 @@ pub async fn v3_binary_encoder( /// [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] pub async fn v3_string_encoder( - mut rx: MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, max_payload: u64, ) -> Payload { let mut data = bytes::BytesMut::new(); @@ -247,7 +246,7 @@ pub async fn v3_string_encoder( let max_packet_size_len = max_payload.checked_ilog10().unwrap_or(0) as usize + 1; // Current size of the payload let current_size = data.len() + PUNCTUATION_LEN + max_packet_size_len; - while let Some(packets) = try_recv_packet(&mut rx, current_size, max_payload, true) { + while let Some(packets) = try_recv_packet(rx.as_mut(), current_size, max_payload, true) { for packet in packets { v3_string_packet_encoder(packet, &mut data); } @@ -255,7 +254,7 @@ pub async fn v3_string_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await; + let packets = recv_packet(rx.as_mut()).await; for packet in packets { v3_string_packet_encoder(packet, &mut data); } @@ -267,9 +266,7 @@ pub async fn v3_string_encoder( #[cfg(test)] mod tests { use bytes::Bytes; - use tokio::sync::Mutex; - - use PacketBuf; + use futures_util::stream; use super::*; const MAX_PAYLOAD: u64 = 100_000; @@ -277,17 +274,14 @@ mod tests { #[tokio::test] async fn encode_v4_payload() { const PAYLOAD: &str = "4hello€\x1ebAQIDBA==\x1e4hello€"; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let rx = Mutex::new(PeekableReceiver::new(rx)); - let rx = rx.lock().await; - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Binary(Bytes::from_static(&[ - 1, 2, 3, 4 - ]))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Binary(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + ]); + let rx = std::pin::pin!(rx.peekable()); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); } @@ -295,31 +289,26 @@ mod tests { #[tokio::test] async fn max_payload_v4() { const MAX_PAYLOAD: u64 = 10; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Binary(Bytes::from_static(&[ - 1, 2, 3, 4 - ]))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Binary(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Message("hello€".into())], + ]); + + let mut rx = std::pin::pin!(rx.peekable()); + { - let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v4_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(data, "4hello€".as_bytes()); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; + let Payload { data, .. } = v4_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; assert_eq!(data, "bAQIDBA==\x1e4hello€".as_bytes()); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; + let Payload { data, .. } = v4_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; assert_eq!(data, "4hello€".as_bytes()); } } @@ -328,18 +317,13 @@ mod tests { #[tokio::test] async fn encode_v3b64_payload() { const PAYLOAD: &str = "7:4hello€10:b4AQIDBA==7:4hello€"; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - let rx = mutex.lock().await; - - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + ]); + + let rx = std::pin::pin!(rx.peekable()); let Payload { data, has_binary } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); assert!(!has_binary); @@ -350,26 +334,20 @@ mod tests { async fn max_payload_v3_b64() { const MAX_PAYLOAD: u64 = 10; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + let rx = stream::iter(vec![ + smallvec::smallvec![Packet::Message("hello€".into())], + smallvec::smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec::smallvec![Packet::Message("hello€".into())], + smallvec::smallvec![Packet::Message("hello€".into())], + ]); + let mut rx = std::pin::pin!(rx.peekable()); + { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v3_string_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€".as_bytes()); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10).await; + let Payload { data, .. } = v3_string_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; assert_eq!(data, "10:b4AQIDBA==7:4hello€7:4hello€".as_bytes()); } } @@ -380,16 +358,13 @@ mod tests { const PAYLOAD: [u8; 20] = [ 0, 9, 255, 52, 104, 101, 108, 108, 111, 226, 130, 172, 1, 5, 255, 4, 1, 2, 3, 4, ]; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - let rx = mutex.lock().await; - - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + ]); + let rx = std::pin::pin!(rx.peekable()); + let Payload { data, has_binary } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); assert!(has_binary); @@ -404,26 +379,21 @@ mod tests { 0, 1, 1, 255, 52, 104, 101, 108, 108, 111, 111, 111, 226, 130, 172, 1, 5, 255, 4, 1, 2, 3, 4, ]; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - tx.try_send(smallvec::smallvec![Packet::Message("hellooo€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hellooo€".into())], + smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Message("hello€".into())], + ]); + let mut rx = std::pin::pin!(rx.peekable()); + { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v3_binary_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v3_binary_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€7:4hello€".as_bytes()); } } diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 977dcef3..32e4a700 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -3,16 +3,12 @@ use crate::{Packet, PacketParseError, ProtocolVersion, packet::PacketBuf}; use bytes::Bytes; -use futures_util::Stream; +use futures_util::{Stream, StreamExt}; use http::HeaderValue; -use tokio::sync::MutexGuard; mod buf; mod decoder; mod encoder; -mod peekable; - -pub use peekable::PeekableReceiver; const PACKET_SEPARATOR_V4: u8 = b'\x1e'; #[cfg(feature = "v3")] @@ -70,11 +66,13 @@ impl Payload { /// Encodes a payload into a byte stream. pub async fn encoder( - rx: MutexGuard<'_, PeekableReceiver>, + rx: impl Stream, #[allow(unused_variables)] protocol: ProtocolVersion, #[cfg(feature = "v3")] supports_binary: bool, max_payload: u64, ) -> Payload { + let rx = std::pin::pin!(rx.peekable()); + #[cfg(feature = "v3")] { match protocol { diff --git a/crates/engineioxide-core/src/payload/peekable.rs b/crates/engineioxide-core/src/payload/peekable.rs deleted file mode 100644 index d4761a57..00000000 --- a/crates/engineioxide-core/src/payload/peekable.rs +++ /dev/null @@ -1,84 +0,0 @@ -use tokio::sync::mpsc::{Receiver, error::TryRecvError}; - -/// Peekable receiver for polling transport -/// It is a thin wrapper around a [`Receiver`](tokio::sync::mpsc::Receiver) that allows to peek the next packet without consuming it -/// -/// Its main goal is to be able to peek the next packet without consuming it to calculate the -/// packet length when using polling transport to check if it fits according to the max_payload setting -#[derive(Debug)] -pub struct PeekableReceiver { - rx: Receiver, - next: Option, -} -impl PeekableReceiver { - /// Create a new peekable receiver - pub fn new(rx: Receiver) -> Self { - Self { rx, next: None } - } - /// Peek the next packet without consuming it - pub fn peek(&mut self) -> Option<&T> { - if self.next.is_none() { - self.next = self.rx.try_recv().ok(); - } - self.next.as_ref() - } - /// Receive the next packet, consuming it - pub async fn recv(&mut self) -> Option { - if self.next.is_none() { - self.rx.recv().await - } else { - self.next.take() - } - } - /// Try to receive the next packet without blocking - pub fn try_recv(&mut self) -> Result { - if self.next.is_none() { - self.rx.try_recv() - } else { - Ok(self.next.take().unwrap()) - } - } - - /// Close the receiver - pub fn close(&mut self) { - self.rx.close() - } -} - -#[cfg(test)] -mod tests { - use tokio::sync::Mutex; - - #[tokio::test] - async fn peek() { - use super::PeekableReceiver; - use crate::Packet; - use tokio::sync::mpsc::channel; - - let (tx, rx) = channel(1); - let rx = Mutex::new(PeekableReceiver::new(rx)); - let mut rx = rx.lock().await; - - assert!(rx.peek().is_none()); - - tx.send(Packet::Ping).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Ping)); - assert_eq!(rx.recv().await, Some(Packet::Ping)); - assert!(rx.peek().is_none()); - - tx.send(Packet::Pong).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Pong)); - assert_eq!(rx.recv().await, Some(Packet::Pong)); - assert!(rx.peek().is_none()); - - tx.send(Packet::Close).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Close)); - assert_eq!(rx.recv().await, Some(Packet::Close)); - assert!(rx.peek().is_none()); - - tx.send(Packet::Close).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Close)); - assert_eq!(rx.recv().await, Some(Packet::Close)); - assert!(rx.peek().is_none()); - } -} diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index b33725a8..846233d6 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -65,9 +65,7 @@ use std::{ use crate::{config::EngineIoConfig, errors::Error}; use bytes::Bytes; -use engineioxide_core::{ - Packet, PacketBuf, ProtocolVersion, Str, TransportType, payload::PeekableReceiver, -}; +use engineioxide_core::{Packet, PacketBuf, ProtocolVersion, Str, TransportType}; use http::request::Parts; use smallvec::{SmallVec, smallvec}; use tokio::{ @@ -197,7 +195,7 @@ where /// Because with polling transport, if the client is not currently polling then the encoder will never be able to close the channel /// /// The channel is made of a [`SmallVec`] of [`Packet`]s so that adjacent packets can be sent atomically. - pub(crate) internal_rx: Mutex>, + pub(crate) internal_rx: Mutex>, /// Channel to send [PacketBuf] to the internal connection internal_tx: mpsc::Sender, @@ -245,7 +243,7 @@ where transport: AtomicU8::new(transport as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(PeekableReceiver::new(internal_rx)), + internal_rx: Mutex::new(internal_rx), internal_tx, heartbeat_rx: Mutex::new(heartbeat_rx), @@ -544,7 +542,7 @@ where transport: AtomicU8::new(TransportType::Websocket as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(PeekableReceiver::new(internal_rx)), + internal_rx: Mutex::new(internal_rx), internal_tx, heartbeat_rx: Mutex::new(heartbeat_rx), diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index c7df4f15..c759ca0a 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -110,7 +110,7 @@ where // If the socket is already locked, it means that the socket is being used by another request // In case of multiple http polling, session should be closed - let rx = match socket.internal_rx.try_lock() { + let mut rx = match socket.internal_rx.try_lock() { Ok(s) => s, Err(_) => { socket.close(DisconnectReason::MultipleHttpPollingError); @@ -118,14 +118,19 @@ where } }; + //TODO: handle closing channel packet better than in the encoding process. + #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] polling request"); let max_payload = engine.config.max_payload; + let rx = rx_stream::ReceiverStream::new(&mut rx); + #[cfg(feature = "v3")] let Payload { data, has_binary } = payload::encoder(rx, protocol, socket.supports_binary, max_payload).await; + #[cfg(not(feature = "v3"))] let Payload { data, has_binary } = payload::encoder(rx, protocol, max_payload).await; @@ -196,3 +201,33 @@ where } Ok(http_response(StatusCode::OK, "ok", false)?) } + +mod rx_stream { + use std::{ + pin::Pin, + task::{Context, Poll}, + }; + + use futures_core::Stream; + use tokio::sync::mpsc::Receiver; + + /// [`ReceiverStream`] is a stream that wraps a tokio::sync::mpsc::Receiver by reference. + /// Allowing to use it as a stream even if it is behind a mutex. + pub struct ReceiverStream<'a, T> { + inner: &'a mut Receiver, + } + + impl<'a, T> ReceiverStream<'a, T> { + pub fn new(inner: &'a mut Receiver) -> Self { + Self { inner } + } + } + + impl<'a, T> Stream for ReceiverStream<'a, T> { + type Item = T; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_recv(cx) + } + } +} From 5da9f12454c444ca0b95cad3c48b7a4027f88cf6 Mon Sep 17 00:00:00 2001 From: totodore Date: Fri, 10 Jul 2026 01:42:57 +0200 Subject: [PATCH 11/17] wip --- crates/engineioxide-client/Cargo.toml | 4 +- crates/engineioxide-client/src/client.rs | 95 ++++++++++---- crates/engineioxide-client/src/lib.rs | 20 ++- .../src/transport/polling.rs | 61 ++++----- crates/engineioxide-client/tests/handshake.rs | 3 +- crates/engineioxide-core/src/packet.rs | 1 + .../engineioxide-core/src/payload/encoder.rs | 116 +++++++++++++++++- crates/engineioxide-core/src/payload/mod.rs | 6 + 8 files changed, 229 insertions(+), 77 deletions(-) diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index 2d7b7fcb..e2547e8b 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -31,7 +31,7 @@ smallvec.workspace = true hyper-util = { workspace = true, features = ["tokio"] } # Tracing -tracing = { workspace = true, optional = true } +tracing = { workspace = true } # TODO: make optional [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } @@ -40,5 +40,5 @@ engineioxide = { path = "../engineioxide", features = ["tracing", "v3"] } [features] v3 = ["engineioxide-core/v3"] -tracing = ["dep:tracing", "engineioxide-core/tracing"] +# tracing = ["dep:tracing", "engineioxide-core/tracing"] __test_harness = [] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index ea6f4223..5eef4c80 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -1,20 +1,18 @@ use std::{ fmt, pin::Pin, - sync::Mutex, - task::{Context, Poll}, + task::{Context, Poll, ready}, }; -use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid}; +use engineioxide_core::{Packet, PacketParseError, Sid}; use futures_core::Stream; use futures_util::{ Sink, SinkExt, StreamExt, stream::{SplitSink, SplitStream}, }; -use tokio::sync::mpsc::{self, error::TrySendError}; use crate::{ - HttpClient, poll, + HttpClient, transport::{Transport, polling::PollingSvc}, }; @@ -25,6 +23,14 @@ type SendPongFut = Pin< >, >; +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("packet parse error")] + PacketParse(#[from] PacketParseError), + #[error("transport error")] + Transport(#[from] Box), +} + pin_project_lite::pin_project! { pub struct Client { #[pin] @@ -35,6 +41,8 @@ pin_project_lite::pin_project! { #[pin] pub transport_tx: SplitSink, Packet>, + should_send_pong: bool, + should_flush: bool, pub sid: Sid, // pub tx: mpsc::Sender, // pub(crate) rx: Mutex>, @@ -46,9 +54,9 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - pub async fn connect(svc: S) -> Result { + pub async fn connect(svc: S) -> Result { let mut inner = HttpClient::new(svc); - let packet = inner.handshake().await.unwrap(); + let packet = inner.handshake().await?; let transport = Transport::Polling { inner }; let (transport_tx, transport_rx) = transport.split(); @@ -56,33 +64,68 @@ where transport_tx, transport_rx, sid: packet.sid, + + should_flush: false, + should_send_pong: false, }; Ok(client) } } +impl Client +where + S::Error: fmt::Debug, + ::Error: fmt::Debug, +{ + #[tracing::instrument(skip(cx))] + fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + ready!(this.transport_tx.as_mut().poll_ready(cx)); + let res = this.transport_tx.as_mut().start_send(Packet::Pong); + *this.should_send_pong = false; + *this.should_flush = true; + Poll::Ready(Ok(res.unwrap())) + } + + #[tracing::instrument(skip(cx))] + fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + ready!(this.transport_tx.as_mut().poll_flush(cx)); + *this.should_flush = false; + Poll::Ready(Ok(())) + } +} impl Stream for Client where S::Error: fmt::Debug, ::Error: fmt::Debug, { - type Item = Result; - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - match poll!(this.transport_rx.poll_next(cx)) { + type Item = Result; + + #[tracing::instrument(skip(cx))] + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.should_send_pong { + //TODO: ret err + self.as_mut().heartbeat(cx); + } + + if self.should_flush { + //TODO: ret err + self.as_mut().flush(cx); + } + + match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - cx.waker().wake_by_ref(); - // let mut tx = self.transport_tx.clone(); - // let fut = async move { - // tx.send(Packet::Pong).await?; - // tx.flush().await - // }; - // this.pending_pong.set(Some(Box::pin(fut))); - - Poll::Pending + dbg!("got ping packet"); + if let Poll::Pending = self.as_mut().heartbeat(cx) { + self.should_send_pong = true; + } + self.poll_next(cx) } - packet => Poll::Ready(packet), + Some(Ok(packet)) => Poll::Ready(Some(Ok(packet))), + Some(Err(e)) => Poll::Ready(Some(Err(e.into()))), + None => Poll::Ready(None), } } } @@ -110,3 +153,13 @@ where self.project().transport_tx.poll_close(cx) } } + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("should_send_pong", &self.should_send_pong) + .field("should_flush", &self.should_flush) + .field("sid", &self.sid) + .finish() + } +} diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index fb39e455..efeed7df 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -1,5 +1,11 @@ -// #![warn(clippy::pedantic)] -#![allow(clippy::similar_names)] +#![allow(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc( + html_logo_url = "https://raw.githubusercontent.com/Totodore/socketioxide/refs/heads/main/.github/logo_dark.svg" +)] +#![doc( + html_favicon_url = "https://raw.githubusercontent.com/Totodore/socketioxide/refs/heads/main/.github/logo_dark.ico" +)] //! Engine.IO client library for Rust. mod client; @@ -7,13 +13,3 @@ mod io; mod transport; pub use crate::client::Client; pub use crate::transport::polling::HttpClient; - -#[macro_export] -macro_rules! poll { - ($expr:expr) => { - match $expr { - std::task::Poll::Pending => return std::task::Poll::Pending, - std::task::Poll::Ready(value) => value, - } - }; -} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 7d63e33c..9d9a7742 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,10 +1,13 @@ use std::collections::VecDeque; +use std::convert::Infallible; use std::fmt; use std::pin::Pin; use std::task::Context; use std::task::Poll; +use std::task::ready; use bytes::Bytes; +use bytes::BytesMut; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; use engineioxide_core::PacketBuf; @@ -12,7 +15,6 @@ use engineioxide_core::PacketParseError; use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; use engineioxide_core::payload; -use engineioxide_core::payload::Payload; use futures_core::Stream; use futures_util::Sink; use futures_util::StreamExt; @@ -22,20 +24,21 @@ use http::Response; use http::StatusCode; use http_body_util::BodyExt; use http_body_util::Full; +use http_body_util::combinators::BoxBody; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; use smallvec::smallvec; -use crate::poll; - -pub trait PollingSvc: HyperSvc>, Response = Response> { +pub trait PollingSvc: + HyperSvc>, Response = Response> +{ type Body: hyper::body::Body + 'static; } impl PollingSvc for S where - S: HyperSvc>, Response = Response>, - >>>::Error: fmt::Debug, + S: HyperSvc>, Response = Response>, + >>>::Error: fmt::Debug, B: hyper::body::Body + 'static, ::Error: std::fmt::Debug + 'static, ::Data: Send + std::fmt::Debug + 'static, @@ -64,11 +67,8 @@ pin_project! { enum PostState { /// TODO: Ideally the queue should gradually encode packets in an async fashion Queuing { - queue: VecDeque - }, - Encoding { - #[pin] - fut: Pin>>, + // queue: VecDeque + bytes: BytesMut, }, Pending { #[pin] @@ -80,7 +80,7 @@ pin_project! { impl Default for PostState { fn default() -> Self { PostState::Queuing { - queue: VecDeque::new(), + bytes: BytesMut::new(), } } } @@ -115,13 +115,12 @@ where } pub async fn handshake(&mut self) -> Result { - #[cfg(feature = "tracing")] tracing::trace!(?self, "handshake request"); let req = Request::builder() .method("GET") .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") - .body(Full::default()) + .body(BoxBody::new(Full::default())) .unwrap(); let res = self.svc.call(req).await; @@ -149,7 +148,6 @@ where type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - #[cfg(feature = "tracing")] tracing::trace!(poll_state = ?self.poll_state, "polling"); let mut poll_state_proj = self.as_mut().project().poll_state.project(); @@ -158,14 +156,16 @@ where let id = self.sid.unwrap(); let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); - let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); + let req = Request::get(uri) + .body(BoxBody::new(Full::default())) + .unwrap(); let fut = self.svc.call(req); self.project().poll_state.set(PollState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } PollStateProj::Pending { ref mut fut } => { - match poll!(fut.as_mut().poll(cx)) { + match ready!(fut.as_mut().poll(cx)) { Ok(res) => { let (parts, body) = res.into_parts(); dbg!(&parts); @@ -183,14 +183,13 @@ where Poll::Pending } Err(err) => { - #[cfg(feature = "tracing")] tracing::debug!(?err, "got body error"); Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) } } } PollStateProj::Decoding { ref mut stream } => { - if let Some(packet) = poll!(stream.poll_next_unpin(cx)) { + if let Some(packet) = ready!(stream.poll_next_unpin(cx)) { dbg!(&packet); Poll::Ready(Some(packet)) } else { @@ -215,7 +214,6 @@ impl Sink for HttpClient { } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { - #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "sending packet"); match self.project().post_state.project() { @@ -227,38 +225,26 @@ impl Sink for HttpClient { } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "flushing"); match self.as_mut().project().post_state.project() { PostStateProj::Queuing { queue } if queue.is_empty() => Poll::Ready(Ok(())), PostStateProj::Queuing { queue } => { - let fut = payload::encoder( - stream::iter(queue.clone()), - ProtocolVersion::V4, - true, - 102400000, - ); - self.project() - .post_state - .set(PostState::Encoding { fut: Box::pin(fut) }); - cx.waker().wake_by_ref(); - Poll::Pending - } - PostStateProj::Encoding { fut } => { - let body = poll!(fut.poll(cx)); + let queue = std::mem::take(queue); + let body = payload::encoder_2(stream::iter(queue), 9999999); let req = Request::post( "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", ) - .body(Full::new(body.data)) //TODO: fix cloning + .body(BoxBody::new(body)) .unwrap(); + let fut = self.svc.call(req); self.project().post_state.set(PostState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } PostStateProj::Pending { fut } => { - match poll!(fut.poll(cx)) { + match ready!(fut.poll(cx)) { Ok(res) => { assert!(res.status().is_success()); self.project().post_state.set(PostState::default()); @@ -290,7 +276,6 @@ impl fmt::Debug for PollState { impl fmt::Debug for PostState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Encoding { .. } => f.debug_struct("Encoding").finish(), Self::Pending { .. } => write!(f, "Pending"), Self::Queuing { .. } => write!(f, "Queuing"), } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 70d9d50b..db1c7b8b 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,4 +1,3 @@ -use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -8,7 +7,7 @@ use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; use engineioxide_client::{Client, HttpClient}; use engineioxide_core::{Packet, Sid}; -use futures_util::{SinkExt, StreamExt, TryFutureExt}; +use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 0d16416d..1b275a70 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -223,6 +223,7 @@ impl Packet { .ok_or(PacketParseError::InvalidPacketType(None))?; let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; let res = match packet_type { + b'0' => Packet::Open(serde_json::from_slice(&value.as_bytes()[1..])?), b'1' => Packet::Close, b'2' if is_upgrade => Packet::PingUpgrade, b'2' => Packet::Ping, diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index af0ac536..469acf75 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -7,14 +7,21 @@ //! * binary encoder (used when there are binary packets and the client supports binary) //! -use std::pin::Pin; +use std::{ + convert::Infallible, + pin::Pin, + task::{Poll, ready}, +}; +use bytes::Bytes; use futures_util::{FutureExt, Stream, StreamExt}; +use http_body::Body; +use pin_project_lite::pin_project; use smallvec::smallvec; use crate::{ packet::PacketBuf, - payload::{Payload, peekable::Peekable}, + payload::{PACKET_SEPARATOR_V4, Payload, peekable::Peekable}, }; #[cfg(feature = "v3")] @@ -54,6 +61,111 @@ async fn poll_packet(mut rx: Pin<&mut Peekable>>) packet } +pin_project! { + pub struct V4StreamEncoder> { + #[pin] + rx: Peekable, + max_payload: u64, + sent_bytes: u64 + } +} + +impl> V4StreamEncoder { + pub fn new(rx: Peekable, max_payload: u64) -> Self { + Self { + rx, + max_payload, + sent_bytes: 0, + } + } +} + +impl> V4StreamEncoder { + fn encode_packets(packets: PacketBuf, size: u64, sent_bytes: &mut u64) -> Option { + let mut data = String::with_capacity(size as usize); + if packets.is_empty() { + return None; + } + + for packet in packets { + let packet: String = packet.into(); + + if *sent_bytes > 0 { + data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); + } + data.push_str(&packet); + } + + *sent_bytes += data.len() as u64 + PACKET_SEPARATOR_V4 as u64; + + Some(data.into()) + } + + fn validate_payload_size( + packets: &PacketBuf, + sent_bytes: &u64, + max_payload: &u64, + ) -> Option { + const PUNCTUATION_LEN: u64 = 1; + let size = packets + .iter() + .map(|p| p.get_size_hint(false)) + .sum::() as u64 + + PUNCTUATION_LEN; + + if *sent_bytes + size > *max_payload { + None + } else { + Some(size) + } + } +} + +impl> Stream for V4StreamEncoder { + type Item = Bytes; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut this = self.project(); + + match this.rx.as_mut().poll_peek(cx) { + Poll::Ready(Some(v)) + if let Some(size) = + Self::validate_payload_size(v, this.sent_bytes, this.max_payload) => + { + *this.sent_bytes += size as u64; + let packets = ready!(this.rx.poll_next(cx)).expect("we just peeked Some packets"); + Poll::Ready(Self::encode_packets(packets, size, this.sent_bytes)) + } + Poll::Ready(Some(_)) => { + #[cfg(feature = "tracing")] + tracing::debug!("payload too big, stopping encoding for this payload"); + Poll::Ready(None) + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending if *this.sent_bytes == 0 => Poll::Pending, + Poll::Pending => Poll::Ready(None), + } + } +} + +impl> Body for V4StreamEncoder { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + match ready!(self.poll_next(cx)) { + Some(frame) => Poll::Ready(Some(Ok(http_body::Frame::data(frame)))), + None => Poll::Ready(None), + } + } +} + /// Encode multiple packets into a string payload according to the /// [engine.io v4 protocol](https://socket.io/fr/docs/v4/engine-io-protocol/#http-long-polling-1) pub async fn v4_encoder( diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 23f153b7..0dc6d808 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -10,6 +10,8 @@ mod buf; mod decoder; mod encoder; +pub use encoder::V4StreamEncoder; + const PACKET_SEPARATOR_V4: u8 = b'\x1e'; #[cfg(feature = "v3")] const STRING_PACKET_SEPARATOR_V3: u8 = b':'; @@ -74,6 +76,10 @@ impl Payload { } } +pub fn encoder_2<'a, S: Stream>(rx: S, max_payload: u64) -> V4StreamEncoder { + V4StreamEncoder::new(peekable::Peekable::new(rx), max_payload) +} + /// Encodes a payload into a byte stream. pub async fn encoder( rx: impl Stream, From 98ab73fcbfacdbb2955aba68e350ac88b37b8d6c Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Jul 2026 17:15:32 +0200 Subject: [PATCH 12/17] feat: polling transport --- crates/engineioxide-client/src/client.rs | 54 +++--- .../engineioxide-client/src/transport/mod.rs | 2 +- .../src/transport/polling.rs | 167 +++++++++--------- crates/engineioxide-client/tests/handshake.rs | 99 +++++------ crates/engineioxide-client/tests/heartbeat.rs | 137 ++++++++++++++ 5 files changed, 282 insertions(+), 177 deletions(-) create mode 100644 crates/engineioxide-client/tests/heartbeat.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 5eef4c80..fdb87cae 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -7,7 +7,7 @@ use std::{ use engineioxide_core::{Packet, PacketParseError, Sid}; use futures_core::Stream; use futures_util::{ - Sink, SinkExt, StreamExt, + Sink, StreamExt, stream::{SplitSink, SplitStream}, }; @@ -16,36 +16,24 @@ use crate::{ transport::{Transport, polling::PollingSvc}, }; -type SendPongFut = Pin< - Box< - dyn Future, Packet> as Sink>::Error>> - + 'static, - >, ->; - #[derive(Debug, thiserror::Error)] -pub enum ClientError { +pub enum ClientError { #[error("packet parse error")] PacketParse(#[from] PacketParseError), #[error("transport error")] - Transport(#[from] Box), + Transport(S::Error), } pin_project_lite::pin_project! { pub struct Client { #[pin] pub transport_rx: SplitStream>, - // TODO: is this the right implementation? We need something that can be driven itself. - // Otherwise we need a way to drive the transport_tx. Normally it should be driven by the user. - // But what if we need to send a PONG packet from the inner lib? #[pin] pub transport_tx: SplitSink, Packet>, should_send_pong: bool, should_flush: bool, pub sid: Sid, - // pub tx: mpsc::Sender, - // pub(crate) rx: Mutex>, } } @@ -55,15 +43,13 @@ where ::Error: fmt::Debug, { pub async fn connect(svc: S) -> Result { - let mut inner = HttpClient::new(svc); - let packet = inner.handshake().await?; - + let (inner, open) = HttpClient::connect(svc).await?; let transport = Transport::Polling { inner }; let (transport_tx, transport_rx) = transport.split(); let client = Client { transport_tx, transport_rx, - sid: packet.sid, + sid: open.sid, should_flush: false, should_send_pong: false, @@ -80,17 +66,24 @@ where #[tracing::instrument(skip(cx))] fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut this = self.project(); - ready!(this.transport_tx.as_mut().poll_ready(cx)); - let res = this.transport_tx.as_mut().start_send(Packet::Pong); + if let Err(e) = ready!(this.transport_tx.as_mut().poll_ready(cx)) { + return Poll::Ready(Err(e)); + } + if let Err(e) = this.transport_tx.as_mut().start_send(Packet::Pong) { + return Poll::Ready(Err(e)); + } + *this.should_send_pong = false; *this.should_flush = true; - Poll::Ready(Ok(res.unwrap())) + Poll::Ready(Ok(())) } #[tracing::instrument(skip(cx))] fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut this = self.project(); - ready!(this.transport_tx.as_mut().poll_flush(cx)); + if let Err(e) = ready!(this.transport_tx.as_mut().poll_flush(cx)) { + return Poll::Ready(Err(e)); + } *this.should_flush = false; Poll::Ready(Ok(())) } @@ -101,24 +94,27 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - type Item = Result; + type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if self.should_send_pong { //TODO: ret err - self.as_mut().heartbeat(cx); + if let Err(e) = ready!(self.as_mut().heartbeat(cx)) { + return Poll::Ready(Some(Err(ClientError::Transport(e)))); + } } if self.should_flush { //TODO: ret err - self.as_mut().flush(cx); + if let Err(e) = ready!(self.as_mut().flush(cx)) { + return Poll::Ready(Some(Err(ClientError::Transport(e)))); + } } match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - dbg!("got ping packet"); - if let Poll::Pending = self.as_mut().heartbeat(cx) { + if self.as_mut().heartbeat(cx).is_pending() { self.should_send_pong = true; } self.poll_next(cx) @@ -135,7 +131,7 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - type Error = (); + type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().transport_tx.poll_ready(cx) diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index d7bd1b1f..3cb3450e 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -49,7 +49,7 @@ where } } impl Sink for Transport { - type Error = (); + type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 9d9a7742..6f52192e 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,33 +1,18 @@ -use std::collections::VecDeque; -use std::convert::Infallible; -use std::fmt; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; -use std::task::ready; +use std::{ + convert::Infallible, + fmt, + pin::Pin, + task::{Context, Poll, ready}, +}; -use bytes::Bytes; -use bytes::BytesMut; -use engineioxide_core::OpenPacket; -use engineioxide_core::Packet; -use engineioxide_core::PacketBuf; -use engineioxide_core::PacketParseError; -use engineioxide_core::ProtocolVersion; -use engineioxide_core::Sid; -use engineioxide_core::payload; +use bytes::{BufMut, Bytes, BytesMut}; +use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, payload}; use futures_core::Stream; -use futures_util::Sink; -use futures_util::StreamExt; -use futures_util::stream; -use http::Request; -use http::Response; -use http::StatusCode; -use http_body_util::BodyExt; -use http_body_util::Full; -use http_body_util::combinators::BoxBody; +use futures_util::{Sink, StreamExt}; +use http::{Request, Response, StatusCode}; +use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; -use smallvec::smallvec; pub trait PollingSvc: HyperSvc>, Response = Response> @@ -48,10 +33,7 @@ where pin_project! { #[project = PollStateProj] - #[derive(Default)] enum PollState { - #[default] - No, Pending { #[pin] fut: F @@ -65,14 +47,14 @@ pin_project! { pin_project! { #[project = PostStateProj] enum PostState { - /// TODO: Ideally the queue should gradually encode packets in an async fashion Queuing { - // queue: VecDeque bytes: BytesMut, }, Pending { #[pin] - fut: F + fut: F, + // TODO: BytesList + bytes: BytesMut, } } } @@ -85,6 +67,17 @@ impl Default for PostState { } } +impl PollState { + fn new_request>(svc: &S, sid: Sid) -> Self { + let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={sid}"); + let req = Request::get(uri) + .body(BoxBody::new(Full::default())) + .unwrap(); + let fut = svc.call(req); + PollState::Pending { fut } + } +} + pin_project! { pub struct HttpClient { @@ -96,7 +89,7 @@ pin_project! { #[pin] post_state: PostState, - sid: Option, + sid: Sid, } } @@ -105,17 +98,8 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - pub fn new(svc: S) -> Self { - Self { - svc, - poll_state: PollState::default(), - post_state: PostState::default(), - sid: None, - } - } - - pub async fn handshake(&mut self) -> Result { - tracing::trace!(?self, "handshake request"); + pub async fn connect(svc: S) -> Result<(Self, OpenPacket), PacketParseError> { + tracing::trace!("handshake request"); let req = Request::builder() .method("GET") @@ -123,7 +107,7 @@ where .body(BoxBody::new(Full::default())) .unwrap(); - let res = self.svc.call(req).await; + let res = svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); let packet = Packet::parse( ProtocolVersion::V4, @@ -132,8 +116,15 @@ where match packet { Packet::Open(open) => { - self.sid = Some(open.sid); - Ok(open) + let poll_state = PollState::new_request(&svc, open.sid); + let client = HttpClient { + svc, + poll_state, + post_state: PostState::default(), + sid: open.sid, + }; + + Ok((client, open)) } _ => Err(PacketParseError::InvalidPacketType(Some('1'))), } @@ -152,23 +143,10 @@ where let mut poll_state_proj = self.as_mut().project().poll_state.project(); match poll_state_proj { - PollStateProj::No => { - let id = self.sid.unwrap(); - let uri = - format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); - let req = Request::get(uri) - .body(BoxBody::new(Full::default())) - .unwrap(); - let fut = self.svc.call(req); - self.project().poll_state.set(PollState::Pending { fut }); - cx.waker().wake_by_ref(); - Poll::Pending - } PollStateProj::Pending { ref mut fut } => { match ready!(fut.as_mut().poll(cx)) { Ok(res) => { let (parts, body) = res.into_parts(); - dbg!(&parts); assert!(parts.status == StatusCode::OK); let body = Box::pin(body); //TODO: implement limited body + Content-Type @@ -190,11 +168,11 @@ where } PollStateProj::Decoding { ref mut stream } => { if let Some(packet) = ready!(stream.poll_next_unpin(cx)) { - dbg!(&packet); Poll::Ready(Some(packet)) } else { - self.project().poll_state.set(PollState::No); - // Should not be needed. + let request = PollState::new_request(&self.svc, self.sid); + self.project().poll_state.set(request); + //check if wake is needed cx.waker().wake_by_ref(); Poll::Pending } @@ -204,46 +182,43 @@ where } impl Sink for HttpClient { - type Error = (); + type Error = S::Error; fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - match self.post_state { - PostState::Queuing { .. } => Poll::Ready(Ok(())), - _ => Poll::Pending, - } + Poll::Ready(Ok(())) } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { tracing::trace!(post_state = ?self.post_state, "sending packet"); - - match self.project().post_state.project() { - PostStateProj::Queuing { queue } => queue.push_back(smallvec![item]), - _ => panic!("unexpected post state"), - } - + self.project().post_state.encode(item); Ok(()) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { tracing::trace!(post_state = ?self.post_state, "flushing"); + let proj = self.as_mut().project().post_state.project(); - match self.as_mut().project().post_state.project() { - PostStateProj::Queuing { queue } if queue.is_empty() => Poll::Ready(Ok(())), - PostStateProj::Queuing { queue } => { - let queue = std::mem::take(queue); - let body = payload::encoder_2(stream::iter(queue), 9999999); - let req = Request::post( - "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", - ) - .body(BoxBody::new(body)) + match proj { + PostStateProj::Queuing { bytes } if bytes.is_empty() => Poll::Ready(Ok(())), + PostStateProj::Queuing { bytes } => { + let body = std::mem::take(bytes).freeze(); + //TODO: handle max body size from open packet + let req = Request::post(format!( + "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={}", + self.sid // TODO: unwrap + )) + .body(BoxBody::new(Full::new(body))) .unwrap(); let fut = self.svc.call(req); - self.project().post_state.set(PostState::Pending { fut }); + self.project().post_state.set(PostState::Pending { + fut, + bytes: BytesMut::new(), + }); cx.waker().wake_by_ref(); Poll::Pending } - PostStateProj::Pending { fut } => { + PostStateProj::Pending { fut, .. } => { match ready!(fut.poll(cx)) { Ok(res) => { assert!(res.status().is_success()); @@ -252,22 +227,38 @@ impl Sink for HttpClient { } Err(err) => { self.project().post_state.set(PostState::default()); - todo!("handle error") + Poll::Ready(Err(err)) } } } } } - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + //TODO: close behavior Poll::Ready(Ok(())) } } +impl PostState { + pub fn encode(self: Pin<&mut Self>, item: Packet) { + const PACKET_SEPARATOR_V4: u8 = b'\x1e'; + let packet: Bytes = item.into(); + let bytes = match self.project() { + PostStateProj::Queuing { bytes } => bytes, + PostStateProj::Pending { bytes, .. } => bytes, + }; + + if !bytes.is_empty() { + bytes.put_u8(PACKET_SEPARATOR_V4); + } + bytes.extend_from_slice(&packet); + } +} + impl fmt::Debug for PollState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::No => write!(f, "No"), Self::Pending { .. } => write!(f, "Pending"), Self::Decoding { .. } => write!(f, "Decoding"), } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index db1c7b8b..20388866 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::time::Duration; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; @@ -21,100 +20,82 @@ enum Event { #[derive(Debug)] struct Handler { - tx: mpsc::Sender, + tx: mpsc::UnboundedSender, } impl Handler { - fn new() -> (Self, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(100); + fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); (Self { tx }, rx) } } +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +/// Build an [`EngineIoService`] with a short ping interval/timeout so the +/// heartbeat fires quickly during tests. +fn service() -> (EngineIoService, mpsc::UnboundedReceiver) { + init_tracing(); + let (handler, rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + (svc, rx) +} + impl EngineIoHandler for Handler { type Data = (); fn on_connect(self: Arc, socket: Arc>) { - self.tx.try_send(Event::Connect(socket.id)).unwrap(); + self.tx.send(Event::Connect(socket.id)).unwrap(); } fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { - self.tx - .try_send(Event::Disconnect(socket.id, reason)) - .unwrap(); + self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); } fn on_message(self: &Arc, msg: Str, socket: Arc>) { - self.tx.try_send(Event::Message(socket.id, msg)).unwrap(); + self.tx + .send(Event::Message(socket.id, msg.clone())) + .unwrap(); + socket.emit(msg).unwrap(); } fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { - self.tx.try_send(Event::Binary(socket.id, data)).unwrap(); + self.tx + .send(Event::Binary(socket.id, data.clone())) + .unwrap(); + socket.emit_binary(data).unwrap(); } } #[tokio::test] async fn handshake() { - tracing_subscriber::fmt::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); - let (handler, mut rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); - let packet = HttpClient::new(svc).handshake().await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(packet.sid)); + let (svc, mut rx) = service(); + let (_, open) = HttpClient::connect(svc).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } #[tokio::test] async fn connect() { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); - let (handler, mut rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); + let (svc, mut rx) = service(); let client = Client::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (ctx, mut crx) = client.split::(); + let (mut ctx, mut crx) = client.split::(); + + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Binary(Bytes::from("Hello".to_string()))) + .await + .unwrap(); while let Some(event) = crx.next().await { match event { Ok(event) => { - dbg!(event); + ctx.send(dbg!(event)).await.unwrap(); } Err(e) => panic!("Error: {e}"), } } } - -#[tokio::test] -async fn spaaam() { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); - let (handler, mut rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); - let client = Client::connect(svc).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (mut ctx, mut crx) = client.split::(); - - tokio::task::LocalSet::new() - .run_until(async move { - tokio::task::spawn_local(async move { - loop { - ctx.send(Packet::Pong).await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - } - }); - - while let Some(event) = crx.next().await { - match event { - Ok(event) => { - dbg!(event); - } - Err(e) => panic!("Error: {e}"), - } - } - }) - .await; -} diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs new file mode 100644 index 00000000..a5b56b6f --- /dev/null +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -0,0 +1,137 @@ +//! Heartbeat mechanism tests. +//! +//! In the engine.io v4 protocol the *server* drives the heartbeat: every +//! `ping_interval` it sends a `Packet::Ping` and expects a `Packet::Pong` back +//! within `ping_timeout`, otherwise it closes the socket with +//! [`DisconnectReason::HeartbeatTimeout`]. +//! +//! The pong is emitted transparently by `Client::poll_next`: a `Ping` is +//! intercepted, a `Pong` is sent and the `Ping` is never surfaced to the user. + +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use engineioxide::config::EngineIoConfig; +use engineioxide::handler::EngineIoHandler; +use engineioxide::service::EngineIoService; +use engineioxide::{DisconnectReason, Socket, Str}; +use engineioxide_client::Client; +use engineioxide_core::{Packet, Sid}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tracing_subscriber::EnvFilter; + +const PING_INTERVAL: Duration = Duration::from_millis(100); +const PING_TIMEOUT: Duration = Duration::from_millis(100); + +#[derive(Debug, PartialEq, Eq)] +enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), +} + +#[derive(Debug)] +struct Handler { + tx: mpsc::Sender, +} + +impl Handler { + fn new() -> (Self, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(100); + (Self { tx }, rx) + } +} + +impl EngineIoHandler for Handler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) { + self.tx.try_send(Event::Connect(socket.id)).unwrap(); + } + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx + .try_send(Event::Disconnect(socket.id, reason)) + .unwrap(); + } + + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx + .try_send(Event::Message(socket.id, msg.clone())) + .unwrap(); + // Echo the message back so the client can observe liveness. + socket.emit(msg).unwrap(); + } + + fn on_binary(self: &Arc, _data: Bytes, _socket: Arc>) {} +} + +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +/// Build an [`EngineIoService`] with a short ping interval/timeout so the +/// heartbeat fires quickly during tests. +fn service() -> (EngineIoService, mpsc::Receiver) { + init_tracing(); + let (handler, rx) = Handler::new(); + let config = EngineIoConfig::builder() + .ping_interval(PING_INTERVAL) + .ping_timeout(PING_TIMEOUT) + .build(); + let svc = EngineIoService::with_config(Arc::new(handler), config); + (svc, rx) +} + +/// A [`Client`] that is continuously polled must auto-respond to the server's +/// `Ping`s with `Pong`s, keeping the connection alive across several ping +/// cycles. The connection must also still be usable afterwards. +#[tokio::test] +async fn heartbeat_keeps_connection_alive() { + let (svc, mut rx) = service(); + + let mut client = Client::connect(svc).await.unwrap(); + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + + // Drive the client for several ping cycles. Any server event during this + // window can only be a disconnect (we send nothing), which would mean the + // heartbeat failed. + let window = PING_INTERVAL * 5; + let deadline = tokio::time::sleep(window); + tokio::pin!(deadline); + tokio::select! { + _ = &mut deadline => (), + ev = rx.recv() => panic!("unexpected server event during heartbeat: {ev:?}"), + // Polling the stream is what lets the client receive `Ping`s and + // emit `Pong`s. `Ping` is consumed internally and never yielded, + // so this branch effectively never resolves. + packet = client.next() => match packet { + Some(Ok(p)) => panic!("unexpected packet during heartbeat: {p:?}"), + Some(Err(e)) => panic!("client stream error during heartbeat: {e:?}"), + None => panic!("client stream ended unexpectedly"), + }, + } + + // The connection survived several ping cycles: prove it is still healthy + // by round-tripping a message (the handler echoes it back). + client.send(Packet::Message("hb".into())).await.unwrap(); + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "hb".into()), + "server should still receive messages after the heartbeat window", + ); + match client.next().await { + Some(Ok(Packet::Message(msg))) => { + assert_eq!(msg, "hb") + } + // Ignore any other packet (should not happen, `Ping` is internal). + Some(Ok(p)) => panic!("unexpected packet: {p:?}"), + Some(Err(e)) => panic!("client stream error: {e:?}"), + None => panic!("client stream ended before echo"), + } +} From fc95b114250f14376f854dfeea2c434d55dbf4b0 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Jul 2026 17:33:59 +0200 Subject: [PATCH 13/17] feat: polling transport --- crates/engineioxide-client/src/client.rs | 32 ++--- crates/engineioxide-client/tests/handshake.rs | 27 +---- crates/engineioxide-client/tests/polling.rs | 112 ++++++++++++++++++ 3 files changed, 124 insertions(+), 47 deletions(-) create mode 100644 crates/engineioxide-client/tests/polling.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index fdb87cae..fb60a9d3 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -65,26 +65,20 @@ where { #[tracing::instrument(skip(cx))] fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - if let Err(e) = ready!(this.transport_tx.as_mut().poll_ready(cx)) { - return Poll::Ready(Err(e)); - } - if let Err(e) = this.transport_tx.as_mut().start_send(Packet::Pong) { - return Poll::Ready(Err(e)); - } + let mut proj = self.project(); + ready!(proj.transport_tx.as_mut().poll_ready(cx))?; + proj.transport_tx.as_mut().start_send(Packet::Pong)?; - *this.should_send_pong = false; - *this.should_flush = true; + *proj.should_send_pong = false; + *proj.should_flush = true; Poll::Ready(Ok(())) } #[tracing::instrument(skip(cx))] fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - if let Err(e) = ready!(this.transport_tx.as_mut().poll_flush(cx)) { - return Poll::Ready(Err(e)); - } - *this.should_flush = false; + let mut proj = self.project(); + ready!(proj.transport_tx.as_mut().poll_flush(cx))?; + *proj.should_flush = false; Poll::Ready(Ok(())) } } @@ -99,17 +93,11 @@ where #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if self.should_send_pong { - //TODO: ret err - if let Err(e) = ready!(self.as_mut().heartbeat(cx)) { - return Poll::Ready(Some(Err(ClientError::Transport(e)))); - } + ready!(self.as_mut().heartbeat(cx)).map_err(ClientError::Transport)?; } if self.should_flush { - //TODO: ret err - if let Err(e) = ready!(self.as_mut().flush(cx)) { - return Poll::Ready(Some(Err(ClientError::Transport(e)))); - } + ready!(self.as_mut().flush(cx)).map_err(ClientError::Transport)?; } match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 20388866..34b19248 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,9 +4,8 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::{Client, HttpClient}; -use engineioxide_core::{Packet, Sid}; -use futures_util::{SinkExt, StreamExt}; +use engineioxide_client::HttpClient; +use engineioxide_core::Sid; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -77,25 +76,3 @@ async fn handshake() { let (_, open) = HttpClient::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } - -#[tokio::test] -async fn connect() { - let (svc, mut rx) = service(); - let client = Client::connect(svc).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (mut ctx, mut crx) = client.split::(); - - ctx.send(Packet::Message("Hello".into())).await.unwrap(); - ctx.send(Packet::Binary(Bytes::from("Hello".to_string()))) - .await - .unwrap(); - - while let Some(event) = crx.next().await { - match event { - Ok(event) => { - ctx.send(dbg!(event)).await.unwrap(); - } - Err(e) => panic!("Error: {e}"), - } - } -} diff --git a/crates/engineioxide-client/tests/polling.rs b/crates/engineioxide-client/tests/polling.rs new file mode 100644 index 00000000..19e2e190 --- /dev/null +++ b/crates/engineioxide-client/tests/polling.rs @@ -0,0 +1,112 @@ +//! Polling mechanism tests. +//! +//! These tests exercise the [`Client`] read/write halves obtained via +//! [`Client::split`]: packets sent through the sink must reach the server and +//! the echoed packets must be surfaced back through the stream. + +use std::sync::Arc; + +use bytes::Bytes; +use engineioxide::handler::EngineIoHandler; +use engineioxide::{DisconnectReason, service::EngineIoService}; +use engineioxide::{Socket, Str}; +use engineioxide_client::Client; +use engineioxide_core::{Packet, Sid}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, PartialEq, Eq)] +enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), + Binary(Sid, Bytes), +} + +#[derive(Debug)] +struct Handler { + tx: mpsc::UnboundedSender, +} + +impl Handler { + fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self { tx }, rx) + } +} + +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +fn service() -> (EngineIoService, mpsc::UnboundedReceiver) { + init_tracing(); + let (handler, rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + (svc, rx) +} + +impl EngineIoHandler for Handler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) { + self.tx.send(Event::Connect(socket.id)).unwrap(); + } + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); + } + + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx + .send(Event::Message(socket.id, msg.clone())) + .unwrap(); + socket.emit(msg).unwrap(); + } + + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { + self.tx + .send(Event::Binary(socket.id, data.clone())) + .unwrap(); + socket.emit_binary(data).unwrap(); + } +} + +/// Packets sent through the write half must reach the server, and the packets +/// the handler echoes back must be surfaced in order through the read half. +#[tokio::test] +async fn round_trip() { + let (svc, mut rx) = service(); + let client = Client::connect(svc).await.unwrap(); + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + let (mut ctx, mut crx) = client.split::(); + + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + .await + .unwrap(); + + // The server observes both packets. + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "Hello".into()) + ); + assert_eq!( + rx.recv().await.unwrap(), + Event::Binary(sid, Bytes::from_static(b"Hello")) + ); + + // And echoes them back through the read half, in order. + match crx.next().await { + Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + other => panic!("expected echoed message, got {other:?}"), + } + match crx.next().await { + Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + other => panic!("expected echoed binary, got {other:?}"), + } +} From 3b252ed1a75ff1cb6960d16e9199f88f128bcf82 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Jul 2026 19:39:11 +0200 Subject: [PATCH 14/17] feat: polling transport --- crates/engineioxide-client/src/client.rs | 5 +- crates/engineioxide-client/src/lib.rs | 2 +- .../engineioxide-client/src/transport/mod.rs | 5 +- .../engineioxide-client/src/transport/ws.rs | 94 +++++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 crates/engineioxide-client/src/transport/ws.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index fb60a9d3..fc738acd 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -11,10 +11,7 @@ use futures_util::{ stream::{SplitSink, SplitStream}, }; -use crate::{ - HttpClient, - transport::{Transport, polling::PollingSvc}, -}; +use crate::transport::{HttpClient, PollingSvc, Transport}; #[derive(Debug, thiserror::Error)] pub enum ClientError { diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index efeed7df..22b2ba74 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -12,4 +12,4 @@ mod client; mod io; mod transport; pub use crate::client::Client; -pub use crate::transport::polling::HttpClient; +pub use crate::transport::HttpClient; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 3cb3450e..338f682c 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -8,9 +8,10 @@ use engineioxide_core::{Packet, PacketParseError, TransportType}; use futures_core::Stream; use futures_util::Sink; -use crate::{HttpClient, transport::polling::PollingSvc}; +pub use crate::transport::polling::{HttpClient, PollingSvc}; -pub mod polling; +mod polling; +mod ws; pin_project_lite::pin_project! { #[project = TransportProj] diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs new file mode 100644 index 00000000..7e3415f2 --- /dev/null +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -0,0 +1,94 @@ +use std::{ + pin::Pin, + task::{Context, Poll, ready}, +}; + +use bytes::Bytes; +use engineioxide::Str; +use futures_core::Stream; +use futures_util::Sink; +use pin_project_lite::pin_project; +use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes}; + +pub struct WsTransport {} + +pub trait SocketIoWebSocket: + Stream> + Sink +{ + type Error; +} + +pub enum SocketIoWsMessage { + Text(Str), + Binary(Bytes), + Close, +} + +impl From for tungstenite::Message { + fn from(value: SocketIoWsMessage) -> Self { + match value { + SocketIoWsMessage::Text(v) => { + tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) + } + SocketIoWsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes.into()), + SocketIoWsMessage::Close => tungstenite::Message::Close(None), + } + } +} +pin_project! { + struct TokioTungsteniteWebSocket { + #[pin] + inner: tokio_tungstenite::WebSocketStream, + } +} + +impl SocketIoWebSocket + for TokioTungsteniteWebSocket +{ + type Error = tungstenite::Error; +} + +impl Sink + for TokioTungsteniteWebSocket +{ + type Error = tungstenite::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: Item) -> Result<(), Self::Error> { + self.project().inner.poll_ready(cx) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + todo!() + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + todo!() + } +} +impl Stream + for TokioTungsteniteWebSocket +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.project().inner.poll_next(cx)) { + Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Text(unsafe { + Str::from_bytes_unchecked(v.into()) + })))), + Some(Ok(Message::Binary(v))) => { + Poll::Ready(Some(Ok(SocketIoWsMessage::Binary(v.into())))) + } + Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Close))), + Some(Ok(_)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => Poll::Pending, + } + } +} From 3e7ca009f8202ea60d4aa28689375c90d98baa78 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 12 Jul 2026 00:25:39 +0200 Subject: [PATCH 15/17] feat: websocket --- Cargo.lock | 2 + crates/engineioxide-client/Cargo.toml | 8 +- crates/engineioxide-client/src/client.rs | 89 +++--- crates/engineioxide-client/src/lib.rs | 4 +- .../engineioxide-client/src/transport/mod.rs | 87 ++++-- .../src/transport/polling.rs | 85 ++++-- .../engineioxide-client/src/transport/ws.rs | 284 ++++++++++++++---- crates/engineioxide-client/tests/fixture.rs | 102 +++++++ crates/engineioxide-client/tests/handshake.rs | 4 +- crates/engineioxide-client/tests/heartbeat.rs | 53 ++++ .../tests/{polling.rs => round_trip.rs} | 38 +++ 11 files changed, 601 insertions(+), 155 deletions(-) create mode 100644 crates/engineioxide-client/tests/fixture.rs rename crates/engineioxide-client/tests/{polling.rs => round_trip.rs} (72%) diff --git a/Cargo.lock b/Cargo.lock index fd681f1a..3f3a3565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,7 +899,9 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-tungstenite", + "tokio-util", "tracing", "tracing-subscriber", ] diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index e2547e8b..d2a963f8 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -35,8 +35,14 @@ tracing = { workspace = true } # TODO: make optional [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } +tokio-stream.workspace = true +tokio-util.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } -engineioxide = { path = "../engineioxide", features = ["tracing", "v3"] } +engineioxide = { path = "../engineioxide", features = [ + "tracing", + "v3", + "__test_harness", +] } [features] v3 = ["engineioxide-core/v3"] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index fc738acd..61d11bb5 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,29 +4,24 @@ use std::{ task::{Context, Poll, ready}, }; -use engineioxide_core::{Packet, PacketParseError, Sid}; +use engineioxide_core::{Packet, Sid}; use futures_core::Stream; use futures_util::{ Sink, StreamExt, stream::{SplitSink, SplitStream}, }; -use crate::transport::{HttpClient, PollingSvc, Transport}; - -#[derive(Debug, thiserror::Error)] -pub enum ClientError { - #[error("packet parse error")] - PacketParse(#[from] PacketParseError), - #[error("transport error")] - Transport(S::Error), -} +use crate::transport::{ + PollingSvc, PollingTransport, PollingTransportError, Transport, TransportError, WebSocket, + WsTransport, WsTransportError, noop_impl::NoopWebSocket, +}; pin_project_lite::pin_project! { - pub struct Client { + pub struct Client { #[pin] - pub transport_rx: SplitStream>, + pub transport_rx: SplitStream>, #[pin] - pub transport_tx: SplitSink, Packet>, + pub transport_tx: SplitSink, Packet>, should_send_pong: bool, should_flush: bool, @@ -34,13 +29,26 @@ pin_project_lite::pin_project! { } } -impl Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - pub async fn connect(svc: S) -> Result { - let (inner, open) = HttpClient::connect(svc).await?; +impl Client { + pub async fn connect_ws(ws: impl Into) -> Result> { + let (inner, open) = WsTransport::connect(ws).await?; + let transport = Transport::Websocket { inner }; + let (transport_tx, transport_rx) = transport.split(); + let client = Client { + transport_tx, + transport_rx, + sid: open.sid, + + should_flush: false, + should_send_pong: false, + }; + + Ok(client) + } +} +impl Client { + pub async fn connect(svc: S) -> Result> { + let (inner, open) = PollingTransport::connect(svc).await?; let transport = Transport::Polling { inner }; let (transport_tx, transport_rx) = transport.split(); let client = Client { @@ -55,13 +63,13 @@ where Ok(client) } } -impl Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ + +impl Client { #[tracing::instrument(skip(cx))] - fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn heartbeat( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { let mut proj = self.project(); ready!(proj.transport_tx.as_mut().poll_ready(cx))?; proj.transport_tx.as_mut().start_send(Packet::Pong)?; @@ -72,7 +80,10 @@ where } #[tracing::instrument(skip(cx))] - fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn flush( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { let mut proj = self.project(); ready!(proj.transport_tx.as_mut().poll_flush(cx))?; *proj.should_flush = false; @@ -80,21 +91,17 @@ where } } -impl Stream for Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Item = Result>; +impl Stream for Client { + type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if self.should_send_pong { - ready!(self.as_mut().heartbeat(cx)).map_err(ClientError::Transport)?; + ready!(self.as_mut().heartbeat(cx))?; } if self.should_flush { - ready!(self.as_mut().flush(cx)).map_err(ClientError::Transport)?; + ready!(self.as_mut().flush(cx))?; } match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { @@ -105,18 +112,14 @@ where self.poll_next(cx) } Some(Ok(packet)) => Poll::Ready(Some(Ok(packet))), - Some(Err(e)) => Poll::Ready(Some(Err(e.into()))), + Some(Err(e)) => Poll::Ready(Some(Err(e))), None => Poll::Ready(None), } } } -impl Sink for Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Error = as Sink>::Error; +impl Sink for Client { + type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().transport_tx.poll_ready(cx) @@ -135,7 +138,7 @@ where } } -impl fmt::Debug for Client { +impl fmt::Debug for Client { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Client") .field("should_send_pong", &self.should_send_pong) diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 22b2ba74..67307ced 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -12,4 +12,6 @@ mod client; mod io; mod transport; pub use crate::client::Client; -pub use crate::transport::HttpClient; +pub use crate::transport::PollingTransport; + +pub use transport::{noop_impl, tungstenite_impl}; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 338f682c..52334186 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -4,30 +4,53 @@ use std::{ task::{Context, Poll}, }; -use engineioxide_core::{Packet, PacketParseError, TransportType}; +use engineioxide_core::{Packet, TransportType}; use futures_core::Stream; use futures_util::Sink; -pub use crate::transport::polling::{HttpClient, PollingSvc}; +pub use crate::transport::polling::{PollingSvc, PollingTransport, PollingTransportError}; +pub use crate::transport::ws::{ + WebSocket, WsTransport, WsTransportError, noop_impl, tungstenite_impl, +}; mod polling; mod ws; +pub enum TransportError { + Polling(PollingTransportError), + Websocket(WsTransportError), +} + +impl fmt::Debug for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TransportError::Polling(e) => write!(f, "polling error: {}", e), + TransportError::Websocket(e) => write!(f, "ws error: {}", e), + } + } +} +impl std::error::Error for TransportError {} + pin_project_lite::pin_project! { #[project = TransportProj] - pub enum Transport { + pub enum Transport { Polling { #[pin] - inner: HttpClient + inner: PollingTransport }, Websocket { #[pin] - inner: HttpClient + inner: WsTransport } } } -impl Transport { +impl Transport { pub fn transport_type(&self) -> TransportType { match self { Transport::Polling { .. } => TransportType::Polling, @@ -36,47 +59,63 @@ impl Transport { } } -impl Stream for Transport -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Item = Result; +impl Stream for Transport { + type Item = Result>; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.as_mut().project() { - TransportProj::Polling { inner } => inner.poll_next(cx), - TransportProj::Websocket { inner } => inner.poll_next(cx), + TransportProj::Polling { inner } => { + inner.poll_next(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_next(cx).map_err(TransportError::Websocket) + } } } } -impl Sink for Transport { - type Error = as Sink>::Error; +impl Sink for Transport { + type Error = TransportError; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { - TransportProj::Polling { inner } => inner.poll_ready(cx), - TransportProj::Websocket { inner } => inner.poll_ready(cx), + TransportProj::Polling { inner } => { + inner.poll_ready(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_ready(cx).map_err(TransportError::Websocket) + } } } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { match self.project() { - TransportProj::Polling { inner } => inner.start_send(item), - TransportProj::Websocket { inner } => inner.start_send(item), + TransportProj::Polling { inner } => { + inner.start_send(item).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.start_send(item).map_err(TransportError::Websocket) + } } } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { - TransportProj::Polling { inner } => inner.poll_flush(cx), - TransportProj::Websocket { inner } => inner.poll_flush(cx), + TransportProj::Polling { inner } => { + inner.poll_flush(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_flush(cx).map_err(TransportError::Websocket) + } } } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { - TransportProj::Polling { inner } => inner.poll_close(cx), - TransportProj::Websocket { inner } => inner.poll_close(cx), + TransportProj::Polling { inner } => { + inner.poll_close(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_close(cx).map_err(TransportError::Websocket) + } } } } diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 6f52192e..3f0ae1f8 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -15,20 +15,28 @@ use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; pub trait PollingSvc: - HyperSvc>, Response = Response> + HyperSvc< + Request>, + Response = Response, + Error = ::Error, + > { - type Body: hyper::body::Body + 'static; + type Body: http_body::Body + 'static; + type Error: fmt::Debug + std::error::Error; + type ResBodyError: fmt::Debug + std::error::Error + 'static; } impl PollingSvc for S where S: HyperSvc>, Response = Response>, - >>>::Error: fmt::Debug, - B: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, + >>>::Error: fmt::Debug + std::error::Error, + B: http_body::Body + 'static, + ::Error: fmt::Debug + std::error::Error + 'static, + ::Data: Send + fmt::Debug + 'static, { type Body = B; + type Error = >>>::Error; + type ResBodyError = ::Error; } pin_project! { @@ -78,8 +86,18 @@ impl PollState { } } +#[derive(Debug, thiserror::Error)] +pub enum PollingTransportError { + #[error("polling error: {0}")] + Polling(::Error), + #[error("polling body error: {0}")] + PollingBody(::ResBodyError), + #[error("packet error: {0}")] + Packet(#[from] PacketParseError), +} + pin_project! { - pub struct HttpClient + pub struct PollingTransport { svc: S, @@ -93,12 +111,8 @@ pin_project! { } } -impl HttpClient -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - pub async fn connect(svc: S) -> Result<(Self, OpenPacket), PacketParseError> { +impl PollingTransport { + pub async fn connect(svc: S) -> Result<(Self, OpenPacket), PollingTransportError> { tracing::trace!("handshake request"); let req = Request::builder() @@ -107,8 +121,15 @@ where .body(BoxBody::new(Full::default())) .unwrap(); - let res = svc.call(req).await; - let body = res.unwrap().collect().await.unwrap(); + let res = svc + .call(req) + .await + .map_err(PollingTransportError::Polling)?; + let body = res + .collect() + .await + .map_err(PollingTransportError::PollingBody)?; + let packet = Packet::parse( ProtocolVersion::V4, String::from_utf8(body.to_bytes().to_vec()).unwrap(), @@ -117,26 +138,24 @@ where match packet { Packet::Open(open) => { let poll_state = PollState::new_request(&svc, open.sid); - let client = HttpClient { + let transport = PollingTransport { svc, poll_state, post_state: PostState::default(), sid: open.sid, }; - Ok((client, open)) + Ok((transport, open)) } - _ => Err(PacketParseError::InvalidPacketType(Some('1'))), + _ => Err(PollingTransportError::Packet( + PacketParseError::InvalidPacketType(None), + )), } } } -impl Stream for HttpClient -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Item = Result; +impl Stream for PollingTransport { + type Item = Result>; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { tracing::trace!(poll_state = ?self.poll_state, "polling"); @@ -162,13 +181,13 @@ where } Err(err) => { tracing::debug!(?err, "got body error"); - Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) + Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload.into()))) } } } PollStateProj::Decoding { ref mut stream } => { if let Some(packet) = ready!(stream.poll_next_unpin(cx)) { - Poll::Ready(Some(packet)) + Poll::Ready(Some(packet.map_err(PollingTransportError::from))) } else { let request = PollState::new_request(&self.svc, self.sid); self.project().poll_state.set(request); @@ -181,8 +200,8 @@ where } } -impl Sink for HttpClient { - type Error = S::Error; +impl Sink for PollingTransport { + type Error = PollingTransportError; fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) @@ -203,9 +222,9 @@ impl Sink for HttpClient { PostStateProj::Queuing { bytes } => { let body = std::mem::take(bytes).freeze(); //TODO: handle max body size from open packet + let sid = self.sid; let req = Request::post(format!( - "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={}", - self.sid // TODO: unwrap + "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={sid}", )) .body(BoxBody::new(Full::new(body))) .unwrap(); @@ -227,7 +246,7 @@ impl Sink for HttpClient { } Err(err) => { self.project().post_state.set(PostState::default()); - Poll::Ready(Err(err)) + Poll::Ready(Err(PollingTransportError::Polling(err))) } } } @@ -272,9 +291,9 @@ impl fmt::Debug for PostState { } } } -impl fmt::Debug for HttpClient { +impl fmt::Debug for PollingTransport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("HttpClient") + f.debug_struct("PollingTransport") .field("poll_state", &self.poll_state) .field("post_state", &self.post_state) .field("sid", &self.sid) diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index 7e3415f2..9cd642c3 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -1,94 +1,276 @@ use std::{ + fmt, pin::Pin, task::{Context, Poll, ready}, }; use bytes::Bytes; -use engineioxide::Str; +use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Str}; use futures_core::Stream; -use futures_util::Sink; +use futures_util::{Sink, StreamExt}; use pin_project_lite::pin_project; use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes}; -pub struct WsTransport {} +pin_project! { + pub struct WsTransport { + #[pin] + inner: S, + } +} + +pub enum WsTransportError { + Websocket(::Error), + Packet(PacketParseError), + Closed, +} +impl From for WsTransportError { + fn from(e: PacketParseError) -> Self { + WsTransportError::Packet(e) + } +} +impl fmt::Debug for WsTransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} +impl fmt::Display for WsTransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WsTransportError::Websocket(e) => write!(f, "websocket error: {}", e), + WsTransportError::Packet(e) => write!(f, "packet error: {}", e), + WsTransportError::Closed => write!(f, "websocket closed"), + } + } +} +impl std::error::Error for WsTransportError {} + +impl WsTransport { + pub fn new(inner: impl Into) -> Self { + Self { + inner: inner.into(), + } + } + + pub async fn connect(inner: impl Into) -> Result<(Self, OpenPacket), WsTransportError> { + tracing::trace!("handshake request"); + let mut ws = Self::new(inner); + + match ws.next().await.ok_or(WsTransportError::Closed)?? { + Packet::Open(open_packet) => Ok((ws, open_packet)), + _ => Err(WsTransportError::Packet( + PacketParseError::InvalidPacketType(None), + )), + } + } +} -pub trait SocketIoWebSocket: - Stream> + Sink +pub trait WebSocket: + Stream::Error>> + + Sink::Error> + + Sized + + Unpin { - type Error; + type Error: fmt::Debug + std::error::Error; } -pub enum SocketIoWsMessage { +pub enum WsMessage { Text(Str), Binary(Bytes), Close, } -impl From for tungstenite::Message { - fn from(value: SocketIoWsMessage) -> Self { - match value { - SocketIoWsMessage::Text(v) => { - tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) +impl WsTransport { + fn parse_packet(&self, msg: WsMessage) -> Result> { + match msg { + WsMessage::Text(msg) => { + let msg_str = unsafe { Str::from_bytes_unchecked(msg.into()) }; + let packet = Packet::parse(ProtocolVersion::V4, msg_str)?; + Ok(packet) + } + WsMessage::Binary(data) => Ok(Packet::Binary(data)), + WsMessage::Close => { + todo!("impl ws close"); } - SocketIoWsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes.into()), - SocketIoWsMessage::Close => tungstenite::Message::Close(None), } } } -pin_project! { - struct TokioTungsteniteWebSocket { - #[pin] - inner: tokio_tungstenite::WebSocketStream, - } -} -impl SocketIoWebSocket - for TokioTungsteniteWebSocket -{ - type Error = tungstenite::Error; +impl Stream for WsTransport { + type Item = Result>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.as_mut().project().inner.poll_next(cx)) { + Some(Ok(msg)) => match self.parse_packet(msg) { + Ok(packet) => Poll::Ready(Some(Ok(packet))), + Err(e) => Poll::Ready(Some(Err(e))), + }, + Some(Err(e)) => Poll::Ready(Some(Err(WsTransportError::Websocket(e)))), + None => Poll::Ready(None), + } + } } -impl Sink - for TokioTungsteniteWebSocket -{ - type Error = tungstenite::Error; +impl Sink for WsTransport { + type Error = WsTransportError; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().inner.poll_ready(cx) + self.project() + .inner + .poll_ready(cx) + .map_err(WsTransportError::Websocket) } - fn start_send(self: Pin<&mut Self>, item: Item) -> Result<(), Self::Error> { - self.project().inner.poll_ready(cx) + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + let msg = match item { + Packet::Binary(bin) => WsMessage::Binary(bin), + Packet::Noop => return Ok(()), + p => WsMessage::Text(String::from(p).into()), + }; + self.project() + .inner + .start_send(msg) + .map_err(WsTransportError::Websocket) } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - todo!() + self.project() + .inner + .poll_flush(cx) + .map_err(WsTransportError::Websocket) } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - todo!() + self.project() + .inner + .poll_close(cx) + .map_err(WsTransportError::Websocket) } } -impl Stream - for TokioTungsteniteWebSocket -{ - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match ready!(self.project().inner.poll_next(cx)) { - Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Text(unsafe { - Str::from_bytes_unchecked(v.into()) - })))), - Some(Ok(Message::Binary(v))) => { - Poll::Ready(Some(Ok(SocketIoWsMessage::Binary(v.into())))) + +pub mod noop_impl { + use std::convert::Infallible; + + use super::*; + + #[derive(Debug, Default)] + pub struct NoopWebSocket; + + impl WebSocket for NoopWebSocket { + type Error = Infallible; + } + impl Stream for NoopWebSocket { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(None) + } + } + impl Sink for NoopWebSocket { + type Error = Infallible; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, _item: WsMessage) -> Result<(), Self::Error> { + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } +} +pub mod tungstenite_impl { + use super::*; + + impl From for tungstenite::Message { + fn from(value: WsMessage) -> Self { + match value { + WsMessage::Text(v) => { + tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) + } + WsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes), + WsMessage::Close => tungstenite::Message::Close(None), } - Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Close))), - Some(Ok(_)) => { - cx.waker().wake_by_ref(); - Poll::Pending + } + } + pin_project! { + pub struct TokioTungsteniteWebSocket { + #[pin] + inner: tokio_tungstenite::WebSocketStream, + } + } + + impl From> for TokioTungsteniteWebSocket + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + fn from(inner: tokio_tungstenite::WebSocketStream) -> Self { + Self { inner } + } + } + + impl WebSocket + for TokioTungsteniteWebSocket + { + type Error = tungstenite::Error; + } + + impl Sink + for TokioTungsteniteWebSocket + { + type Error = tungstenite::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + self.project().inner.start_send(item.into()) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_flush(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_close(cx) + } + } + + impl Stream + for TokioTungsteniteWebSocket + { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.project().inner.poll_next(cx)) { + Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(WsMessage::Text(unsafe { + Str::from_bytes_unchecked(v.into()) + })))), + Some(Ok(Message::Binary(v))) => Poll::Ready(Some(Ok(WsMessage::Binary(v)))), + Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(WsMessage::Close))), + Some(Ok(_)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => Poll::Pending, } - Some(Err(e)) => Poll::Ready(Some(Err(e))), - None => Poll::Pending, } } } diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs new file mode 100644 index 00000000..196169ae --- /dev/null +++ b/crates/engineioxide-client/tests/fixture.rs @@ -0,0 +1,102 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Bytes; +use engineioxide::handler::EngineIoHandler; +use engineioxide::service::EngineIoService; +use engineioxide_client::Client; +use engineioxide_client::tungstenite_impl::TokioTungsteniteWebSocket; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key}; +use tokio_tungstenite::tungstenite::protocol::Role; +use tokio_util::io::StreamReader; + +pin_project_lite::pin_project! { + pub struct StreamImpl { + tx: mpsc::UnboundedSender>, + #[pin] + rx: StreamReader>, Bytes>, + } +} +impl StreamImpl { + pub fn new( + tx: mpsc::UnboundedSender>, + rx: mpsc::UnboundedReceiver>, + ) -> Self { + Self { + tx, + rx: StreamReader::new(UnboundedReceiverStream::new(rx)), + } + } +} + +impl AsyncRead for StreamImpl { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.project().rx.poll_read(cx, buf) + } +} +impl AsyncWrite for StreamImpl { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let len = buf.len(); + self.project() + .tx + .send(Ok(Bytes::copy_from_slice(buf))) + .unwrap(); + Poll::Ready(Ok(len)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +pub async fn client_ws_connect( + svc: EngineIoService, +) -> Client, TokioTungsteniteWebSocket> { + let (tx, rx) = mpsc::unbounded_channel(); + let (tx1, rx1) = mpsc::unbounded_channel(); + + let parts = Request::builder() + .method("GET") + .header("Host", "127.0.0.1") + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .uri("ws://127.0.0.1/engine.io/?EIO=4&transport=websocket") + .body(http_body_util::Empty::::new()) + .unwrap() + .into_parts() + .0; + + let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( + StreamImpl::new(tx1, rx), + Role::Client, + Default::default(), + ) + .await; + + tokio::spawn(svc.ws_init( + StreamImpl::new(tx, rx1), + engineioxide::ProtocolVersion::V4, + None, + parts, + )); + + Client::connect_ws(ws).await.unwrap() +} diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 34b19248..0834468f 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,7 +4,7 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::HttpClient; +use engineioxide_client::PollingTransport; use engineioxide_core::Sid; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -73,6 +73,6 @@ impl EngineIoHandler for Handler { #[tokio::test] async fn handshake() { let (svc, mut rx) = service(); - let (_, open) = HttpClient::connect(svc).await.unwrap(); + let (_, open) = PollingTransport::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index a5b56b6f..c5ec10f6 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -22,6 +22,10 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +use crate::fixture::client_ws_connect; + +mod fixture; + const PING_INTERVAL: Duration = Duration::from_millis(100); const PING_TIMEOUT: Duration = Duration::from_millis(100); @@ -135,3 +139,52 @@ async fn heartbeat_keeps_connection_alive() { None => panic!("client stream ended before echo"), } } + +/// A [`Client`] that is continuously polled must auto-respond to the server's +/// `Ping`s with `Pong`s, keeping the connection alive across several ping +/// cycles. The connection must also still be usable afterwards. +#[tokio::test] +async fn heartbeat_keeps_connection_alive_websocket() { + let (svc, mut rx) = service(); + + let mut client = client_ws_connect(svc).await; + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + + // Drive the client for several ping cycles. Any server event during this + // window can only be a disconnect (we send nothing), which would mean the + // heartbeat failed. + let window = PING_INTERVAL * 5; + let deadline = tokio::time::sleep(window); + tokio::pin!(deadline); + tokio::select! { + _ = &mut deadline => (), + ev = rx.recv() => panic!("unexpected server event during heartbeat: {ev:?}"), + // Polling the stream is what lets the client receive `Ping`s and + // emit `Pong`s. `Ping` is consumed internally and never yielded, + // so this branch effectively never resolves. + packet = client.next() => match packet { + Some(Ok(p)) => panic!("unexpected packet during heartbeat: {p:?}"), + Some(Err(e)) => panic!("client stream error during heartbeat: {e:?}"), + None => panic!("client stream ended unexpectedly"), + }, + } + + // The connection survived several ping cycles: prove it is still healthy + // by round-tripping a message (the handler echoes it back). + client.send(Packet::Message("hb".into())).await.unwrap(); + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "hb".into()), + "server should still receive messages after the heartbeat window", + ); + match client.next().await { + Some(Ok(Packet::Message(msg))) => { + assert_eq!(msg, "hb") + } + // Ignore any other packet (should not happen, `Ping` is internal). + Some(Ok(p)) => panic!("unexpected packet: {p:?}"), + Some(Err(e)) => panic!("client stream error: {e:?}"), + None => panic!("client stream ended before echo"), + } +} diff --git a/crates/engineioxide-client/tests/polling.rs b/crates/engineioxide-client/tests/round_trip.rs similarity index 72% rename from crates/engineioxide-client/tests/polling.rs rename to crates/engineioxide-client/tests/round_trip.rs index 19e2e190..1534816d 100644 --- a/crates/engineioxide-client/tests/polling.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -16,6 +16,8 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +mod fixture; + #[derive(Debug, PartialEq, Eq)] enum Event { Connect(Sid), @@ -110,3 +112,39 @@ async fn round_trip() { other => panic!("expected echoed binary, got {other:?}"), } } + +/// Packets sent through the write half must reach the server, and the packets +/// the handler echoes back must be surfaced in order through the read half. +#[tokio::test] +async fn round_trip_ws() { + let (svc, mut rx) = service(); + let client = fixture::client_ws_connect(svc).await; + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + let (mut ctx, mut crx) = client.split::(); + + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + .await + .unwrap(); + + // The server observes both packets. + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "Hello".into()) + ); + assert_eq!( + rx.recv().await.unwrap(), + Event::Binary(sid, Bytes::from_static(b"Hello")) + ); + + // And echoes them back through the read half, in order. + match crx.next().await { + Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + other => panic!("expected echoed message, got {other:?}"), + } + match crx.next().await { + Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + other => panic!("expected echoed binary, got {other:?}"), + } +} From efb4d771ed06730dabef11058e0638a4e52bbe6e Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 12 Jul 2026 00:46:59 +0200 Subject: [PATCH 16/17] feat: eioevent --- crates/engineioxide-client/src/client.rs | 74 +++++++++++-------- crates/engineioxide-client/src/event.rs | 20 +++++ crates/engineioxide-client/src/lib.rs | 2 + crates/engineioxide-client/tests/heartbeat.rs | 12 +-- .../engineioxide-client/tests/round_trip.rs | 24 +++--- 5 files changed, 82 insertions(+), 50 deletions(-) create mode 100644 crates/engineioxide-client/src/event.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 61d11bb5..f48b5508 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -6,25 +6,24 @@ use std::{ use engineioxide_core::{Packet, Sid}; use futures_core::Stream; -use futures_util::{ - Sink, StreamExt, - stream::{SplitSink, SplitStream}, -}; - -use crate::transport::{ - PollingSvc, PollingTransport, PollingTransportError, Transport, TransportError, WebSocket, - WsTransport, WsTransportError, noop_impl::NoopWebSocket, +use futures_util::Sink; + +use crate::{ + EioEvent, + transport::{ + PollingSvc, PollingTransport, PollingTransportError, Transport, TransportError, WebSocket, + WsTransport, WsTransportError, noop_impl::NoopWebSocket, + }, }; pin_project_lite::pin_project! { pub struct Client { #[pin] - pub transport_rx: SplitStream>, - #[pin] - pub transport_tx: SplitSink, Packet>, + pub transport: Transport, should_send_pong: bool, should_flush: bool, + closing: bool, pub sid: Sid, } } @@ -33,14 +32,13 @@ impl Client { pub async fn connect_ws(ws: impl Into) -> Result> { let (inner, open) = WsTransport::connect(ws).await?; let transport = Transport::Websocket { inner }; - let (transport_tx, transport_rx) = transport.split(); let client = Client { - transport_tx, - transport_rx, + transport, sid: open.sid, should_flush: false, should_send_pong: false, + closing: false, }; Ok(client) @@ -50,14 +48,13 @@ impl Client { pub async fn connect(svc: S) -> Result> { let (inner, open) = PollingTransport::connect(svc).await?; let transport = Transport::Polling { inner }; - let (transport_tx, transport_rx) = transport.split(); let client = Client { - transport_tx, - transport_rx, + transport, sid: open.sid, should_flush: false, should_send_pong: false, + closing: false, }; Ok(client) @@ -71,8 +68,8 @@ impl Client { cx: &mut Context<'_>, ) -> Poll>> { let mut proj = self.project(); - ready!(proj.transport_tx.as_mut().poll_ready(cx))?; - proj.transport_tx.as_mut().start_send(Packet::Pong)?; + ready!(proj.transport.as_mut().poll_ready(cx))?; + proj.transport.as_mut().start_send(Packet::Pong)?; *proj.should_send_pong = false; *proj.should_flush = true; @@ -85,17 +82,22 @@ impl Client { cx: &mut Context<'_>, ) -> Poll>> { let mut proj = self.project(); - ready!(proj.transport_tx.as_mut().poll_flush(cx))?; + ready!(proj.transport.as_mut().poll_flush(cx))?; *proj.should_flush = false; Poll::Ready(Ok(())) } } impl Stream for Client { - type Item = Result>; + type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closing { + ready!(self.project().transport.poll_close(cx))?; + return Poll::Ready(None); + } + if self.should_send_pong { ready!(self.as_mut().heartbeat(cx))?; } @@ -104,37 +106,45 @@ impl Stream for Client { ready!(self.as_mut().flush(cx))?; } - match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { + match ready!(self.as_mut().project().transport.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - if self.as_mut().heartbeat(cx).is_pending() { - self.should_send_pong = true; - } + *self.as_mut().project().should_send_pong = true; self.poll_next(cx) } - Some(Ok(packet)) => Poll::Ready(Some(Ok(packet))), + Some(Ok(Packet::Close)) => { + *self.as_mut().project().closing = true; + cx.waker().wake_by_ref(); // wake up to close the transport + Poll::Ready(Some(Ok(EioEvent::Disconnect))) + } + Some(Ok(Packet::Message(v))) => Poll::Ready(Some(Ok(EioEvent::Message(v)))), + Some(Ok(Packet::Binary(v))) => Poll::Ready(Some(Ok(EioEvent::Binary(v)))), + Some(Ok(v)) => unreachable!("unexpected msg {v:?}"), Some(Err(e)) => Poll::Ready(Some(Err(e))), None => Poll::Ready(None), } } } -impl Sink for Client { +impl Sink for Client { type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport_tx.poll_ready(cx) + self.project().transport.poll_ready(cx) } - fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { - self.project().transport_tx.start_send(item) + fn start_send(self: Pin<&mut Self>, event: EioEvent) -> Result<(), Self::Error> { + if let Some(packet) = event.into() { + self.project().transport.start_send(packet)?; + } + Ok(()) } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport_tx.poll_flush(cx) + self.project().transport.poll_flush(cx) } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport_tx.poll_close(cx) + self.project().transport.poll_close(cx) } } diff --git a/crates/engineioxide-client/src/event.rs b/crates/engineioxide-client/src/event.rs new file mode 100644 index 00000000..5894a962 --- /dev/null +++ b/crates/engineioxide-client/src/event.rs @@ -0,0 +1,20 @@ +use bytes::Bytes; +use engineioxide_core::{Packet, Str}; + +#[derive(Debug, PartialEq, Eq)] +pub enum EioEvent { + Connect, + Disconnect, + Message(Str), + Binary(Bytes), +} + +impl From for Option { + fn from(value: EioEvent) -> Option { + match value { + EioEvent::Message(msg) => Some(Packet::Message(msg)), + EioEvent::Binary(bin) => Some(Packet::Binary(bin)), + _ => None, + } + } +} diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 67307ced..372014db 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -9,9 +9,11 @@ //! Engine.IO client library for Rust. mod client; +mod event; mod io; mod transport; pub use crate::client::Client; +pub use crate::event::EioEvent; pub use crate::transport::PollingTransport; pub use transport::{noop_impl, tungstenite_impl}; diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index c5ec10f6..d44ca913 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -16,8 +16,8 @@ use engineioxide::config::EngineIoConfig; use engineioxide::handler::EngineIoHandler; use engineioxide::service::EngineIoService; use engineioxide::{DisconnectReason, Socket, Str}; -use engineioxide_client::Client; -use engineioxide_core::{Packet, Sid}; +use engineioxide_client::{Client, EioEvent}; +use engineioxide_core::Sid; use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -123,14 +123,14 @@ async fn heartbeat_keeps_connection_alive() { // The connection survived several ping cycles: prove it is still healthy // by round-tripping a message (the handler echoes it back). - client.send(Packet::Message("hb".into())).await.unwrap(); + client.send(EioEvent::Message("hb".into())).await.unwrap(); assert_eq!( rx.recv().await.unwrap(), Event::Message(sid, "hb".into()), "server should still receive messages after the heartbeat window", ); match client.next().await { - Some(Ok(Packet::Message(msg))) => { + Some(Ok(EioEvent::Message(msg))) => { assert_eq!(msg, "hb") } // Ignore any other packet (should not happen, `Ping` is internal). @@ -172,14 +172,14 @@ async fn heartbeat_keeps_connection_alive_websocket() { // The connection survived several ping cycles: prove it is still healthy // by round-tripping a message (the handler echoes it back). - client.send(Packet::Message("hb".into())).await.unwrap(); + client.send(EioEvent::Message("hb".into())).await.unwrap(); assert_eq!( rx.recv().await.unwrap(), Event::Message(sid, "hb".into()), "server should still receive messages after the heartbeat window", ); match client.next().await { - Some(Ok(Packet::Message(msg))) => { + Some(Ok(EioEvent::Message(msg))) => { assert_eq!(msg, "hb") } // Ignore any other packet (should not happen, `Ping` is internal). diff --git a/crates/engineioxide-client/tests/round_trip.rs b/crates/engineioxide-client/tests/round_trip.rs index 1534816d..9d3c6bac 100644 --- a/crates/engineioxide-client/tests/round_trip.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -10,8 +10,8 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::Client; -use engineioxide_core::{Packet, Sid}; +use engineioxide_client::{Client, EioEvent}; +use engineioxide_core::Sid; use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -85,10 +85,10 @@ async fn round_trip() { let client = Client::connect(svc).await.unwrap(); let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - let (mut ctx, mut crx) = client.split::(); + let (mut ctx, mut crx) = client.split::(); - ctx.send(Packet::Message("Hello".into())).await.unwrap(); - ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + ctx.send(EioEvent::Message("Hello".into())).await.unwrap(); + ctx.send(EioEvent::Binary(Bytes::from_static(b"Hello"))) .await .unwrap(); @@ -104,11 +104,11 @@ async fn round_trip() { // And echoes them back through the read half, in order. match crx.next().await { - Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + Some(Ok(EioEvent::Message(msg))) => assert_eq!(msg, "Hello"), other => panic!("expected echoed message, got {other:?}"), } match crx.next().await { - Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + Some(Ok(EioEvent::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), other => panic!("expected echoed binary, got {other:?}"), } } @@ -121,10 +121,10 @@ async fn round_trip_ws() { let client = fixture::client_ws_connect(svc).await; let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - let (mut ctx, mut crx) = client.split::(); + let (mut ctx, mut crx) = client.split::(); - ctx.send(Packet::Message("Hello".into())).await.unwrap(); - ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + ctx.send(EioEvent::Message("Hello".into())).await.unwrap(); + ctx.send(EioEvent::Binary(Bytes::from_static(b"Hello"))) .await .unwrap(); @@ -140,11 +140,11 @@ async fn round_trip_ws() { // And echoes them back through the read half, in order. match crx.next().await { - Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + Some(Ok(EioEvent::Message(msg))) => assert_eq!(msg, "Hello"), other => panic!("expected echoed message, got {other:?}"), } match crx.next().await { - Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + Some(Ok(EioEvent::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), other => panic!("expected echoed binary, got {other:?}"), } } From dec543b45d3baeaf79dbf066e0f2bc8c9cc685a7 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 12 Jul 2026 00:59:06 +0200 Subject: [PATCH 17/17] feat: eioevent --- crates/engineioxide-client/src/io.rs | 1 - crates/engineioxide-client/src/lib.rs | 5 ++-- crates/engineioxide-client/tests/fixture.rs | 30 ++++++++++++------- crates/engineioxide-client/tests/handshake.rs | 19 ++++++++++-- crates/engineioxide-client/tests/heartbeat.rs | 4 +-- 5 files changed, 39 insertions(+), 20 deletions(-) delete mode 100644 crates/engineioxide-client/src/io.rs diff --git a/crates/engineioxide-client/src/io.rs b/crates/engineioxide-client/src/io.rs deleted file mode 100644 index 8b137891..00000000 --- a/crates/engineioxide-client/src/io.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 372014db..7babd7b9 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -10,10 +10,9 @@ mod client; mod event; -mod io; mod transport; + pub use crate::client::Client; pub use crate::event::EioEvent; pub use crate::transport::PollingTransport; - -pub use transport::{noop_impl, tungstenite_impl}; +pub use transport::{WsTransport, noop_impl, tungstenite_impl}; diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index 196169ae..fd1a1881 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -21,6 +21,7 @@ pin_project_lite::pin_project! { rx: StreamReader>, Bytes>, } } + impl StreamImpl { pub fn new( tx: mpsc::UnboundedSender>, @@ -65,9 +66,9 @@ impl AsyncWrite for StreamImpl { } } -pub async fn client_ws_connect( +pub async fn tungstenite_client( svc: EngineIoService, -) -> Client, TokioTungsteniteWebSocket> { +) -> tokio_tungstenite::WebSocketStream { let (tx, rx) = mpsc::unbounded_channel(); let (tx1, rx1) = mpsc::unbounded_channel(); @@ -84,19 +85,26 @@ pub async fn client_ws_connect( .into_parts() .0; - let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( - StreamImpl::new(tx1, rx), - Role::Client, - Default::default(), - ) - .await; - - tokio::spawn(svc.ws_init( + svc.ws_init( StreamImpl::new(tx, rx1), engineioxide::ProtocolVersion::V4, None, parts, - )); + ) + .await + .unwrap(); + tokio_tungstenite::WebSocketStream::from_raw_socket( + StreamImpl::new(tx1, rx), + Role::Client, + Default::default(), + ) + .await +} + +pub async fn client_ws_connect( + svc: EngineIoService, +) -> Client, TokioTungsteniteWebSocket> { + let ws = tungstenite_client(svc).await; Client::connect_ws(ws).await.unwrap() } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 0834468f..811befaf 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,11 +4,16 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::PollingTransport; +use engineioxide_client::tungstenite_impl::TokioTungsteniteWebSocket; +use engineioxide_client::{PollingTransport, WsTransport}; use engineioxide_core::Sid; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +use crate::fixture::tungstenite_client; + +mod fixture; + #[derive(Debug, PartialEq, Eq)] enum Event { Connect(Sid), @@ -71,8 +76,18 @@ impl EngineIoHandler for Handler { } #[tokio::test] -async fn handshake() { +async fn handshake_polling() { let (svc, mut rx) = service(); let (_, open) = PollingTransport::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } + +#[tokio::test] +async fn handshake_websocket() { + let (svc, mut rx) = service(); + let ws = tungstenite_client(svc).await; + let (_, open) = WsTransport::>::connect(ws) + .await + .unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); +} diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index d44ca913..010cd942 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -22,8 +22,6 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; -use crate::fixture::client_ws_connect; - mod fixture; const PING_INTERVAL: Duration = Duration::from_millis(100); @@ -147,7 +145,7 @@ async fn heartbeat_keeps_connection_alive() { async fn heartbeat_keeps_connection_alive_websocket() { let (svc, mut rx) = service(); - let mut client = client_ws_connect(svc).await; + let mut client = fixture::client_ws_connect(svc).await; let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid));