Implement wire protocol messages and codec in wayray-protocol

Message types for control, display, and input channels with serde
derives. Length-prefixed postcard codec with encode/decode/framing.
Seven round-trip tests covering all message types and edge cases.
This commit is contained in:
Till Wegmueller 2026-04-07 15:35:48 +02:00
parent 8d8af48676
commit 0762cb1fa3
6 changed files with 409 additions and 0 deletions

114
Cargo.lock generated
View file

@ -91,6 +91,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -175,6 +184,12 @@ version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@ -254,6 +269,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -322,6 +346,12 @@ dependencies = [
"libc",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@ -387,6 +417,18 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equivalent"
version = "1.0.2"
@ -534,6 +576,15 @@ dependencies = [
"xml-rs",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -549,6 +600,20 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@ -729,6 +794,15 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
@ -1160,6 +1234,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1378,6 +1465,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
@ -1391,6 +1484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@ -1552,6 +1646,21 @@ dependencies = [
"serde",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "supports-color"
version = "3.0.2"
@ -2079,6 +2188,11 @@ dependencies = [
[[package]]
name = "wayray-protocol"
version = "0.1.0"
dependencies = [
"postcard",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "web-sys"

View file

@ -18,3 +18,6 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
miette = { version = "7", features = ["fancy"] }
thiserror = "2"
serde = { version = "1", features = ["derive"] }
postcard = { version = "1", features = ["alloc"] }
zstd = "0.13"

View file

@ -5,3 +5,6 @@ version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
postcard.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,156 @@
//! Length-prefixed message framing for QUIC streams.
//!
//! Each message is encoded as: 4-byte LE length prefix + postcard payload.
use serde::{Serialize, de::DeserializeOwned};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CodecError {
#[error("serialization failed: {0}")]
Serialize(#[from] postcard::Error),
#[error("incomplete message: need {needed} bytes, have {available}")]
Incomplete { needed: usize, available: usize },
}
/// Encode a message to a length-prefixed byte vector.
///
/// Format: [4-byte LE length][postcard payload]
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, CodecError> {
let payload = postcard::to_allocvec(msg)?;
let len = (payload.len() as u32).to_le_bytes();
let mut buf = Vec::with_capacity(4 + payload.len());
buf.extend_from_slice(&len);
buf.extend_from_slice(&payload);
Ok(buf)
}
/// Decode a message from a payload byte slice (after length prefix is removed).
pub fn decode<T: DeserializeOwned>(data: &[u8]) -> Result<T, CodecError> {
Ok(postcard::from_bytes(data)?)
}
/// Read a length prefix from a byte slice.
///
/// Returns `Some((length, remaining_slice))` if at least 4 bytes are available,
/// `None` otherwise.
pub fn read_length_prefix(data: &[u8]) -> Option<(u32, &[u8])> {
if data.len() < 4 {
return None;
}
let len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
Some((len, &data[4..]))
}
/// Try to extract a complete framed message from a buffer.
///
/// Returns `Some((message, bytes_consumed))` if a complete message is available,
/// `None` if more data is needed.
pub fn try_decode_framed<T: DeserializeOwned>(
buf: &[u8],
) -> Result<Option<(T, usize)>, CodecError> {
let Some((len, rest)) = read_length_prefix(buf) else {
return Ok(None);
};
let len = len as usize;
if rest.len() < len {
return Ok(None);
}
let msg = decode(&rest[..len])?;
Ok(Some((msg, 4 + len)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::*;
#[test]
fn control_message_roundtrip() {
let msg = ControlMessage::ClientHello(ClientHello {
version: 1,
capabilities: vec!["display".to_string()],
});
let encoded = encode(&msg).unwrap();
let (len, payload) = read_length_prefix(&encoded).unwrap();
let decoded: ControlMessage = decode(&payload[..len as usize]).unwrap();
assert_eq!(decoded, msg);
}
#[test]
fn display_message_roundtrip() {
let msg = DisplayMessage::FrameUpdate(FrameUpdate {
sequence: 42,
regions: vec![DamageRegion {
x: 10,
y: 20,
width: 100,
height: 50,
data: vec![0, 1, 2, 3],
}],
});
let encoded = encode(&msg).unwrap();
let (len, payload) = read_length_prefix(&encoded).unwrap();
let decoded: DisplayMessage = decode(&payload[..len as usize]).unwrap();
assert_eq!(decoded, msg);
}
#[test]
fn input_message_roundtrip() {
let msg = InputMessage::Keyboard(KeyboardEvent {
keycode: 38,
state: KeyState::Pressed,
time: 12345,
});
let encoded = encode(&msg).unwrap();
let (len, payload) = read_length_prefix(&encoded).unwrap();
let decoded: InputMessage = decode(&payload[..len as usize]).unwrap();
assert_eq!(decoded, msg);
}
#[test]
fn try_decode_framed_complete() {
let msg = ControlMessage::Ping(Ping { timestamp: 999 });
let encoded = encode(&msg).unwrap();
let result: Option<(ControlMessage, usize)> = try_decode_framed(&encoded).unwrap();
let (decoded, consumed) = result.unwrap();
assert_eq!(decoded, msg);
assert_eq!(consumed, encoded.len());
}
#[test]
fn try_decode_framed_incomplete() {
let msg = ControlMessage::Ping(Ping { timestamp: 999 });
let encoded = encode(&msg).unwrap();
// Only provide half the data
let partial = &encoded[..encoded.len() / 2];
let result: Option<(ControlMessage, usize)> = try_decode_framed(partial).unwrap();
assert!(result.is_none());
}
#[test]
fn empty_frame_update() {
let msg = DisplayMessage::FrameUpdate(FrameUpdate {
sequence: 0,
regions: vec![],
});
let encoded = encode(&msg).unwrap();
let (len, payload) = read_length_prefix(&encoded).unwrap();
let decoded: DisplayMessage = decode(&payload[..len as usize]).unwrap();
assert_eq!(decoded, msg);
}
#[test]
fn pointer_motion_roundtrip() {
let msg = InputMessage::PointerMotion(PointerMotion {
x: 100.5,
y: 200.75,
time: 54321,
});
let encoded = encode(&msg).unwrap();
let (len, payload) = read_length_prefix(&encoded).unwrap();
let decoded: InputMessage = decode(&payload[..len as usize]).unwrap();
assert_eq!(decoded, msg);
}
}

View file

@ -1,3 +1,11 @@
//! WayRay wire protocol definitions.
//!
//! Shared between wrsrvd (server) and wrclient (client).
//! Messages are serialized with postcard and framed with a 4-byte
//! length prefix for transmission over QUIC streams.
pub mod codec;
pub mod messages;
/// Current protocol version. Incremented on breaking changes.
pub const PROTOCOL_VERSION: u32 = 1;

View file

@ -0,0 +1,125 @@
//! WayRay wire protocol message types.
//!
//! Organized by channel: control (bidirectional), display (server→client),
//! and input (client→server). Serialized with postcard over QUIC streams.
use serde::{Deserialize, Serialize};
// ── Control channel (bidirectional) ─────────────────────────────────
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClientHello {
pub version: u32,
pub capabilities: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServerHello {
pub version: u32,
pub session_id: u64,
pub output_width: u32,
pub output_height: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Ping {
pub timestamp: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Pong {
pub timestamp: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrameAck {
pub sequence: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ControlMessage {
ClientHello(ClientHello),
ServerHello(ServerHello),
Ping(Ping),
Pong(Pong),
FrameAck(FrameAck),
}
// ── Display channel (server → client, unidirectional) ───────────────
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DamageRegion {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
/// zstd-compressed XOR diff pixel data for this region.
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrameUpdate {
pub sequence: u64,
pub regions: Vec<DamageRegion>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DisplayMessage {
FrameUpdate(FrameUpdate),
}
// ── Input channel (client → server, unidirectional) ─────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeyState {
Pressed,
Released,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ButtonState {
Pressed,
Released,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Axis {
Horizontal,
Vertical,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeyboardEvent {
pub keycode: u32,
pub state: KeyState,
pub time: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PointerMotion {
pub x: f64,
pub y: f64,
pub time: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PointerButton {
pub button: u32,
pub state: ButtonState,
pub time: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PointerAxis {
pub axis: Axis,
pub value: f64,
pub time: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum InputMessage {
Keyboard(KeyboardEvent),
PointerMotion(PointerMotion),
PointerButton(PointerButton),
PointerAxis(PointerAxis),
}