From 1c0fe3243186074867d01e7329b998e230188b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 00:31:37 +0000 Subject: [PATCH 1/7] Add moq-transport-15 (draft) support alongside existing draft-14 Rust changes for dual v14/v15 support: - version.rs: Add Draft15 variant and ALPN_15 constant - parameters.rs: Add MessageParameters type for v15 message-level params - request.rs: Add RequestOk (0x07) and RequestError (0x05) for v15 - setup.rs: v15 setup omits version fields (ALPN negotiation only) - subscribe.rs: v15 moves fields into MessageParameters - publish.rs: v15 moves fields into MessageParameters - fetch.rs: v15 reorders fields and uses MessageParameters - group.rs: v15 adds 0x30-0x3d no-priority GroupFlags range - track.rs: v15 uses MessageParameters for TrackStatus - session.rs: Version-aware control message dispatch - publisher.rs: Version-aware responses (RequestOk/Error for v15) - subscriber.rs: Version-aware responses, accept no-priority groups All 119 tests pass, full workspace compiles. https://claude.ai/code/session_01Tov5RP9yLYA2ALQAfqNDQf --- rs/moq-lite/src/ietf/fetch.rs | 139 ++++++++---- rs/moq-lite/src/ietf/group.rs | 70 +++++- rs/moq-lite/src/ietf/parameters.rs | 164 +++++++++++++++ rs/moq-lite/src/ietf/publish.rs | 202 ++++++++++++------ rs/moq-lite/src/ietf/publisher.rs | 106 ++++++---- rs/moq-lite/src/ietf/request.rs | 62 +++++- rs/moq-lite/src/ietf/session.rs | 81 ++++--- rs/moq-lite/src/ietf/setup.rs | 64 ++++-- rs/moq-lite/src/ietf/subscribe.rs | 328 +++++++++++++++++++---------- rs/moq-lite/src/ietf/subscriber.rs | 100 +++++++-- rs/moq-lite/src/ietf/track.rs | 45 ++-- rs/moq-lite/src/ietf/version.rs | 4 + 12 files changed, 1030 insertions(+), 335 deletions(-) diff --git a/rs/moq-lite/src/ietf/fetch.rs b/rs/moq-lite/src/ietf/fetch.rs index 95d6af3a5..8b4d32532 100644 --- a/rs/moq-lite/src/ietf/fetch.rs +++ b/rs/moq-lite/src/ietf/fetch.rs @@ -4,7 +4,7 @@ use crate::{ Path, coding::{Decode, DecodeError, Encode}, ietf::{ - GroupOrder, Location, Message, Parameters, RequestId, Version, + GroupOrder, Location, Message, MessageParameters, Parameters, RequestId, Version, namespace::{decode_namespace, encode_namespace}, }, }; @@ -106,8 +106,6 @@ pub struct Fetch<'a> { pub subscriber_priority: u8, pub group_order: GroupOrder, pub fetch_type: FetchType<'a>, - // fetch type specific - // parameters } impl Message for Fetch<'_> { @@ -115,26 +113,59 @@ impl Message for Fetch<'_> { fn encode_msg(&self, w: &mut W, version: Version) { self.request_id.encode(w, version); - self.subscriber_priority.encode(w, version); - self.group_order.encode(w, version); - self.fetch_type.encode(w, version); - // parameters - 0u8.encode(w, version); + + match version { + Version::Draft14 => { + self.subscriber_priority.encode(w, version); + self.group_order.encode(w, version); + self.fetch_type.encode(w, version); + 0u8.encode(w, version); // no parameters + } + Version::Draft15 => { + // v15: request_id, fetch_type, parameters (with subscriber_priority, group_order) + self.fetch_type.encode(w, version); + let mut params = MessageParameters::default(); + params.set_subscriber_priority(self.subscriber_priority); + params.set_group_order(u8::from(self.group_order) as u64); + params.encode(w, version); + } + } } fn decode_msg(buf: &mut B, version: Version) -> Result { let request_id = RequestId::decode(buf, version)?; - let subscriber_priority = u8::decode(buf, version)?; - let group_order = GroupOrder::decode(buf, version)?; - let fetch_type = FetchType::decode(buf, version)?; - // parameters - let _params = Parameters::decode(buf, version)?; - Ok(Self { - request_id, - subscriber_priority, - group_order, - fetch_type, - }) + + match version { + Version::Draft14 => { + let subscriber_priority = u8::decode(buf, version)?; + let group_order = GroupOrder::decode(buf, version)?; + let fetch_type = FetchType::decode(buf, version)?; + let _params = Parameters::decode(buf, version)?; + Ok(Self { + request_id, + subscriber_priority, + group_order, + fetch_type, + }) + } + Version::Draft15 => { + let fetch_type = FetchType::decode(buf, version)?; + let params = MessageParameters::decode(buf, version)?; + + let subscriber_priority = params.subscriber_priority().unwrap_or(128); + let group_order = match params.group_order() { + Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + None => GroupOrder::Descending, + }; + + Ok(Self { + request_id, + subscriber_priority, + group_order, + fetch_type, + }) + } + } } } @@ -144,33 +175,65 @@ pub struct FetchOk { pub group_order: GroupOrder, pub end_of_track: bool, pub end_location: Location, - // parameters } impl Message for FetchOk { const ID: u64 = 0x18; fn encode_msg(&self, w: &mut W, version: Version) { self.request_id.encode(w, version); - self.group_order.encode(w, version); - self.end_of_track.encode(w, version); - self.end_location.encode(w, version); - // parameters - 0u8.encode(w, version); + + match version { + Version::Draft14 => { + self.group_order.encode(w, version); + self.end_of_track.encode(w, version); + self.end_location.encode(w, version); + 0u8.encode(w, version); // no parameters + } + Version::Draft15 => { + // v15: request_id, end_of_track(8), end_location, parameters + self.end_of_track.encode(w, version); + self.end_location.encode(w, version); + let mut params = MessageParameters::default(); + params.set_group_order(u8::from(self.group_order) as u64); + params.encode(w, version); + } + } } fn decode_msg(buf: &mut B, version: Version) -> Result { let request_id = RequestId::decode(buf, version)?; - let group_order = GroupOrder::decode(buf, version)?; - let end_of_track = bool::decode(buf, version)?; - let end_location = Location::decode(buf, version)?; - // parameters - let _params = Parameters::decode(buf, version)?; - Ok(Self { - request_id, - group_order, - end_of_track, - end_location, - }) + + match version { + Version::Draft14 => { + let group_order = GroupOrder::decode(buf, version)?; + let end_of_track = bool::decode(buf, version)?; + let end_location = Location::decode(buf, version)?; + let _params = Parameters::decode(buf, version)?; + Ok(Self { + request_id, + group_order, + end_of_track, + end_location, + }) + } + Version::Draft15 => { + let end_of_track = bool::decode(buf, version)?; + let end_location = Location::decode(buf, version)?; + let params = MessageParameters::decode(buf, version)?; + + let group_order = match params.group_order() { + Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + None => GroupOrder::Descending, + }; + + Ok(Self { + request_id, + group_order, + end_of_track, + end_location, + }) + } + } } } @@ -241,9 +304,11 @@ impl Decode for FetchHeader { } } -// Currently unused. +/// Fetch object serialization (v14 format). +/// v15 adds SerializationFlags for delta encoding but we skip that for now. pub struct FetchObject { /* + v14: Group ID (i), Subgroup ID (i), Object ID (i), diff --git a/rs/moq-lite/src/ietf/group.rs b/rs/moq-lite/src/ietf/group.rs index f800b7828..3be887714 100644 --- a/rs/moq-lite/src/ietf/group.rs +++ b/rs/moq-lite/src/ietf/group.rs @@ -37,19 +37,29 @@ pub struct GroupFlags { // There's an implicit end marker when the stream is closed. pub has_end: bool, + + // v15: whether priority is present in the header. + // When false (0x30 base), priority inherits from the control message. + pub has_priority: bool, } impl GroupFlags { + // v14 range: 0x10-0x1d (priority always present) pub const START: u64 = 0x10; pub const END: u64 = 0x1d; + // v15 adds: 0x30-0x3d (priority absent, inherits from control message) + pub const START_NO_PRIORITY: u64 = 0x30; + pub const END_NO_PRIORITY: u64 = 0x3d; + pub fn encode(&self) -> u64 { assert!( !self.has_subgroup || !self.has_subgroup_object, "has_subgroup and has_subgroup_object cannot be true at the same time" ); - let mut id: u64 = Self::START; // Base value + let base = if self.has_priority { Self::START } else { Self::START_NO_PRIORITY }; + let mut id: u64 = base; if self.has_extensions { id |= 0x01; } @@ -66,14 +76,18 @@ impl GroupFlags { } pub fn decode(id: u64) -> Result { - if !(Self::START..=Self::END).contains(&id) { + let (has_priority, base_id) = if (Self::START..=Self::END).contains(&id) { + (true, id) + } else if (Self::START_NO_PRIORITY..=Self::END_NO_PRIORITY).contains(&id) { + (false, id - (Self::START_NO_PRIORITY - Self::START)) + } else { return Err(DecodeError::InvalidValue); - } + }; - let has_extensions = (id & 0x01) != 0; - let has_subgroup_object = (id & 0x02) != 0; - let has_subgroup = (id & 0x04) != 0; - let has_end = (id & 0x08) != 0; + let has_extensions = (base_id & 0x01) != 0; + let has_subgroup_object = (base_id & 0x02) != 0; + let has_subgroup = (base_id & 0x04) != 0; + let has_end = (base_id & 0x08) != 0; if has_subgroup && has_subgroup_object { return Err(DecodeError::InvalidValue); @@ -84,6 +98,7 @@ impl GroupFlags { has_subgroup, has_subgroup_object, has_end, + has_priority, }) } } @@ -95,6 +110,7 @@ impl Default for GroupFlags { has_subgroup: false, has_subgroup_object: false, has_end: true, + has_priority: true, } } } @@ -122,8 +138,10 @@ impl Encode for GroupHeader { self.sub_group_id.encode(w, version.clone()); } - // Publisher priority - self.publisher_priority.encode(w, version); + // Publisher priority (only if has_priority flag is set) + if self.flags.has_priority { + self.publisher_priority.encode(w, version); + } } } @@ -138,7 +156,12 @@ impl Decode for GroupHeader { false => 0, }; - let publisher_priority = u8::decode(r, version)?; + // Priority present only if has_priority flag is set + let publisher_priority = if flags.has_priority { + u8::decode(r, version)? + } else { + 128 // Default priority when absent + }; Ok(Self { track_alias, @@ -163,6 +186,7 @@ mod tests { assert!(!flags.has_subgroup_object); assert!(!flags.has_extensions); assert!(!flags.has_end); + assert!(flags.has_priority); assert_eq!(flags.encode(), 0x10); // Type 0x11: No subgroup field, Subgroup ID = 0, Extensions, No end @@ -256,4 +280,30 @@ mod tests { // Invalid: Both has_subgroup and has_subgroup_object (would be 0x16) assert!(GroupFlags::decode(0x16).is_err()); } + + #[test] + fn test_group_flags_no_priority_range() { + // v15: 0x30 range = same flags as 0x10 range but no priority + let flags = GroupFlags::decode(0x30).unwrap(); + assert!(!flags.has_priority); + assert!(!flags.has_subgroup); + assert!(!flags.has_extensions); + assert!(!flags.has_end); + assert_eq!(flags.encode(), 0x30); + + let flags = GroupFlags::decode(0x38).unwrap(); + assert!(!flags.has_priority); + assert!(flags.has_end); + assert_eq!(flags.encode(), 0x38); + + let flags = GroupFlags::decode(0x3D).unwrap(); + assert!(!flags.has_priority); + assert!(flags.has_subgroup); + assert!(flags.has_extensions); + assert!(flags.has_end); + assert_eq!(flags.encode(), 0x3D); + + // Invalid: Both has_subgroup and has_subgroup_object in no-priority range + assert!(GroupFlags::decode(0x36).is_err()); + } } diff --git a/rs/moq-lite/src/ietf/parameters.rs b/rs/moq-lite/src/ietf/parameters.rs index 405ee4002..17077ecac 100644 --- a/rs/moq-lite/src/ietf/parameters.rs +++ b/rs/moq-lite/src/ietf/parameters.rs @@ -6,6 +6,8 @@ use crate::coding::*; const MAX_PARAMS: u64 = 64; +// ---- Setup Parameters (used in CLIENT_SETUP/SERVER_SETUP) ---- + #[derive(Debug, Copy, Clone, FromPrimitive, IntoPrimitive, Eq, Hash, PartialEq)] #[repr(u64)] pub enum ParameterVarInt { @@ -99,3 +101,165 @@ impl Parameters { self.bytes.insert(kind, value); } } + +// ---- Message Parameters (used in Subscribe, Publish, Fetch, etc.) ---- +// Uses raw u64 keys since parameter IDs have different meanings from setup parameters. + +#[derive(Default, Debug, Clone)] +pub struct MessageParameters { + vars: HashMap, + bytes: HashMap>, +} + +impl Decode for MessageParameters { + fn decode(mut r: &mut R, version: V) -> Result { + let mut vars = HashMap::new(); + let mut bytes = HashMap::new(); + + let count = u64::decode(r, version.clone())?; + + if count > MAX_PARAMS { + return Err(DecodeError::TooMany); + } + + for _ in 0..count { + let kind = u64::decode(r, version.clone())?; + + if kind % 2 == 0 { + match vars.entry(kind) { + hash_map::Entry::Occupied(_) => return Err(DecodeError::Duplicate), + hash_map::Entry::Vacant(entry) => entry.insert(u64::decode(&mut r, version.clone())?), + }; + } else { + match bytes.entry(kind) { + hash_map::Entry::Occupied(_) => return Err(DecodeError::Duplicate), + hash_map::Entry::Vacant(entry) => entry.insert(Vec::::decode(&mut r, version.clone())?), + }; + } + } + + Ok(MessageParameters { vars, bytes }) + } +} + +impl Encode for MessageParameters { + fn encode(&self, w: &mut W, version: V) { + (self.vars.len() + self.bytes.len()).encode(w, version.clone()); + + for (kind, value) in self.vars.iter() { + kind.encode(w, version.clone()); + value.encode(w, version.clone()); + } + + for (kind, value) in self.bytes.iter() { + kind.encode(w, version.clone()); + value.encode(w, version.clone()); + } + } +} + +impl MessageParameters { + // Varint parameter IDs (even) + const DELIVERY_TIMEOUT: u64 = 0x02; + const MAX_CACHE_DURATION: u64 = 0x04; + const EXPIRES: u64 = 0x08; + const PUBLISHER_PRIORITY: u64 = 0x0E; + const FORWARD: u64 = 0x10; + const SUBSCRIBER_PRIORITY: u64 = 0x20; + const GROUP_ORDER: u64 = 0x22; + + // Bytes parameter IDs (odd) + #[allow(dead_code)] + const AUTHORIZATION_TOKEN: u64 = 0x03; + const LARGEST_OBJECT: u64 = 0x09; + const SUBSCRIPTION_FILTER: u64 = 0x21; + + // --- Varint accessors --- + + pub fn delivery_timeout(&self) -> Option { + self.vars.get(&Self::DELIVERY_TIMEOUT).copied() + } + + pub fn set_delivery_timeout(&mut self, v: u64) { + self.vars.insert(Self::DELIVERY_TIMEOUT, v); + } + + pub fn max_cache_duration(&self) -> Option { + self.vars.get(&Self::MAX_CACHE_DURATION).copied() + } + + pub fn set_max_cache_duration(&mut self, v: u64) { + self.vars.insert(Self::MAX_CACHE_DURATION, v); + } + + pub fn expires(&self) -> Option { + self.vars.get(&Self::EXPIRES).copied() + } + + pub fn set_expires(&mut self, v: u64) { + self.vars.insert(Self::EXPIRES, v); + } + + pub fn publisher_priority(&self) -> Option { + self.vars.get(&Self::PUBLISHER_PRIORITY).map(|v| *v as u8) + } + + pub fn set_publisher_priority(&mut self, v: u8) { + self.vars.insert(Self::PUBLISHER_PRIORITY, v as u64); + } + + pub fn forward(&self) -> Option { + self.vars.get(&Self::FORWARD).map(|v| *v != 0) + } + + pub fn set_forward(&mut self, v: bool) { + self.vars.insert(Self::FORWARD, v as u64); + } + + pub fn subscriber_priority(&self) -> Option { + self.vars.get(&Self::SUBSCRIBER_PRIORITY).map(|v| *v as u8) + } + + pub fn set_subscriber_priority(&mut self, v: u8) { + self.vars.insert(Self::SUBSCRIBER_PRIORITY, v as u64); + } + + pub fn group_order(&self) -> Option { + self.vars.get(&Self::GROUP_ORDER).copied() + } + + pub fn set_group_order(&mut self, v: u64) { + self.vars.insert(Self::GROUP_ORDER, v); + } + + // --- Bytes accessors --- + + /// Get largest object location (encoded as group_id varint + object_id varint) + pub fn largest_object(&self) -> Option { + let data = self.bytes.get(&Self::LARGEST_OBJECT)?; + let mut buf = bytes::Bytes::from(data.clone()); + let group = u64::decode(&mut buf, ()).ok()?; + let object = u64::decode(&mut buf, ()).ok()?; + Some(super::Location { group, object }) + } + + pub fn set_largest_object(&mut self, loc: &super::Location) { + let mut buf = Vec::new(); + loc.group.encode(&mut buf, ()); + loc.object.encode(&mut buf, ()); + self.bytes.insert(Self::LARGEST_OBJECT, buf); + } + + /// Get subscription filter (encoded as filter_type varint [+ filter data]) + pub fn subscription_filter(&self) -> Option { + let data = self.bytes.get(&Self::SUBSCRIPTION_FILTER)?; + let mut buf = bytes::Bytes::from(data.clone()); + super::FilterType::decode(&mut buf, ()).ok() + } + + pub fn set_subscription_filter(&mut self, ft: super::FilterType) { + let mut buf = Vec::new(); + ft.encode(&mut buf, ()); + self.bytes.insert(Self::SUBSCRIPTION_FILTER, buf); + } +} diff --git a/rs/moq-lite/src/ietf/publish.rs b/rs/moq-lite/src/ietf/publish.rs index 69d3221b9..d13999e98 100644 --- a/rs/moq-lite/src/ietf/publish.rs +++ b/rs/moq-lite/src/ietf/publish.rs @@ -110,7 +110,8 @@ use crate::{ Path, coding::{Decode, DecodeError, Encode}, ietf::{ - FilterType, GroupOrder, Location, Message, Parameters, RequestId, Version, + FilterType, GroupOrder, Location, Message, MessageParameters, Parameters, RequestId, + Version, namespace::{decode_namespace, encode_namespace}, }, }; @@ -169,17 +170,31 @@ impl Message for Publish<'_> { encode_namespace(w, &self.track_namespace, version); self.track_name.encode(w, version); self.track_alias.encode(w, version); - self.group_order.encode(w, version); - if let Some(location) = &self.largest_location { - true.encode(w, version); - location.encode(w, version); - } else { - false.encode(w, version); - } - self.forward.encode(w, version); - // parameters - 0u8.encode(w, version); + match version { + Version::Draft14 => { + self.group_order.encode(w, version); + if let Some(location) = &self.largest_location { + true.encode(w, version); + location.encode(w, version); + } else { + false.encode(w, version); + } + + self.forward.encode(w, version); + // parameters + 0u8.encode(w, version); + } + Version::Draft15 => { + let mut params = MessageParameters::default(); + params.set_group_order(u8::from(self.group_order) as u64); + if let Some(location) = &self.largest_location { + params.set_largest_object(location); + } + params.set_forward(self.forward); + params.encode(w, version); + } + } } fn decode_msg(r: &mut R, version: Version) -> Result { @@ -187,24 +202,50 @@ impl Message for Publish<'_> { let track_namespace = decode_namespace(r, version)?; let track_name = Cow::::decode(r, version)?; let track_alias = u64::decode(r, version)?; - let group_order = GroupOrder::decode(r, version)?; - let content_exists = bool::decode(r, version)?; - let largest_location = match content_exists { - true => Some(Location::decode(r, version)?), - false => None, - }; - let forward = bool::decode(r, version)?; - // parameters - let _params = Parameters::decode(r, version)?; - Ok(Self { - request_id, - track_namespace, - track_name, - track_alias, - group_order, - largest_location, - forward, - }) + + match version { + Version::Draft14 => { + let group_order = GroupOrder::decode(r, version)?; + let content_exists = bool::decode(r, version)?; + let largest_location = match content_exists { + true => Some(Location::decode(r, version)?), + false => None, + }; + let forward = bool::decode(r, version)?; + // parameters + let _params = Parameters::decode(r, version)?; + + Ok(Self { + request_id, + track_namespace, + track_name, + track_alias, + group_order, + largest_location, + forward, + }) + } + Version::Draft15 => { + let params = MessageParameters::decode(r, version)?; + + let group_order = match params.group_order() { + Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + None => GroupOrder::Descending, + }; + let largest_location = params.largest_object(); + let forward = params.forward().unwrap_or(true); + + Ok(Self { + request_id, + track_namespace, + track_name, + track_alias, + group_order, + largest_location, + forward, + }) + } + } } } @@ -223,45 +264,82 @@ impl Message for PublishOk { fn encode_msg(&self, w: &mut W, version: Version) { self.request_id.encode(w, version); - self.forward.encode(w, version); - self.subscriber_priority.encode(w, version); - self.group_order.encode(w, version); - self.filter_type.encode(w, version); - assert!( - matches!(self.filter_type, FilterType::LargestObject | FilterType::NextGroup), - "absolute subscribe not supported" - ); - // no parameters - 0u8.encode(w, version); + + match version { + Version::Draft14 => { + self.forward.encode(w, version); + self.subscriber_priority.encode(w, version); + self.group_order.encode(w, version); + self.filter_type.encode(w, version); + assert!( + matches!(self.filter_type, FilterType::LargestObject | FilterType::NextGroup), + "absolute subscribe not supported" + ); + // no parameters + 0u8.encode(w, version); + } + Version::Draft15 => { + let mut params = MessageParameters::default(); + params.set_forward(self.forward); + params.set_subscriber_priority(self.subscriber_priority); + params.set_group_order(u8::from(self.group_order) as u64); + params.set_subscription_filter(self.filter_type); + params.encode(w, version); + } + } } fn decode_msg(r: &mut R, version: Version) -> Result { let request_id = RequestId::decode(r, version)?; - let forward = bool::decode(r, version)?; - let subscriber_priority = u8::decode(r, version)?; - let group_order = GroupOrder::decode(r, version)?; - let filter_type = FilterType::decode(r, version)?; - match filter_type { - FilterType::AbsoluteStart => { - let _start = Location::decode(r, version)?; + + match version { + Version::Draft14 => { + let forward = bool::decode(r, version)?; + let subscriber_priority = u8::decode(r, version)?; + let group_order = GroupOrder::decode(r, version)?; + let filter_type = FilterType::decode(r, version)?; + match filter_type { + FilterType::AbsoluteStart => { + let _start = Location::decode(r, version)?; + } + FilterType::AbsoluteRange => { + let _start = Location::decode(r, version)?; + let _end_group = u64::decode(r, version)?; + } + FilterType::NextGroup | FilterType::LargestObject => {} + }; + + // no parameters + let _params = Parameters::decode(r, version)?; + + Ok(Self { + request_id, + forward, + subscriber_priority, + group_order, + filter_type, + }) } - FilterType::AbsoluteRange => { - let _start = Location::decode(r, version)?; - let _end_group = u64::decode(r, version)?; + Version::Draft15 => { + let params = MessageParameters::decode(r, version)?; + + let forward = params.forward().unwrap_or(true); + let subscriber_priority = params.subscriber_priority().unwrap_or(128); + let group_order = match params.group_order() { + Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + None => GroupOrder::Descending, + }; + let filter_type = params.subscription_filter().unwrap_or(FilterType::LargestObject); + + Ok(Self { + request_id, + forward, + subscriber_priority, + group_order, + filter_type, + }) } - FilterType::NextGroup | FilterType::LargestObject => {} - }; - - // no parameters - let _params = Parameters::decode(r, version)?; - - Ok(Self { - request_id, - forward, - subscriber_priority, - group_order, - filter_type, - }) + } } } diff --git a/rs/moq-lite/src/ietf/publisher.rs b/rs/moq-lite/src/ietf/publisher.rs index e3e08c79e..9f001aff5 100644 --- a/rs/moq-lite/src/ietf/publisher.rs +++ b/rs/moq-lite/src/ietf/publisher.rs @@ -7,7 +7,7 @@ use web_transport_trait::SendStream; use crate::{ Error, Origin, OriginConsumer, Track, TrackConsumer, coding::Writer, - ietf::{self, Control, FetchHeader, FetchType, FilterType, GroupOrder, Location, RequestId, Version}, + ietf::{self, Control, FetchHeader, FetchType, FilterType, GroupOrder, Location, MessageParameters, RequestId, Version}, model::GroupConsumer, }; @@ -80,12 +80,7 @@ impl Publisher { tracing::info!(id = %request_id, broadcast = %absolute, %track, "subscribed started"); let Some(broadcast) = self.origin.consume_broadcast(&msg.track_namespace) else { - self.control.send(ietf::SubscribeError { - request_id, - error_code: 404, - reason_phrase: "Broadcast not found".into(), - })?; - return Ok(()); + return self.send_subscribe_error(request_id, 404, "Broadcast not found"); }; let track = Track { @@ -137,12 +132,24 @@ impl Publisher { Ok(()) } + /// Send a subscribe error, using RequestError for v15. + fn send_subscribe_error(&self, request_id: RequestId, error_code: u64, reason: &str) -> Result<(), Error> { + match self.version { + Version::Draft14 => self.control.send(ietf::SubscribeError { + request_id, + error_code, + reason_phrase: reason.into(), + }), + Version::Draft15 => self.control.send(ietf::RequestError { + request_id, + error_code, + reason_phrase: reason.into(), + }), + } + } + pub fn recv_subscribe_update(&mut self, msg: ietf::SubscribeUpdate) -> Result<(), Error> { - self.control.send(ietf::SubscribeError { - request_id: msg.request_id, - error_code: 500, - reason_phrase: "subscribe update not supported".into(), - }) + self.send_subscribe_error(msg.request_id, 500, "subscribe update not supported") } async fn run_track( @@ -323,6 +330,17 @@ impl Publisher { Ok(()) } + pub fn recv_request_ok(&mut self, _msg: &ietf::RequestOk) -> Result<(), Error> { + // v15: generic OK response. For publish_namespace, we don't care. + Ok(()) + } + + pub fn recv_request_error(&mut self, msg: &ietf::RequestError<'_>) -> Result<(), Error> { + // v15: generic error response. Log it like publish_namespace_error. + tracing::warn!(?msg, "request error"); + Ok(()) + } + pub fn recv_subscribe_namespace(&mut self, _msg: ietf::SubscribeNamespace<'_>) -> Result<(), Error> { // We don't care, we're sending all announcements anyway. Ok(()) @@ -350,51 +368,29 @@ impl Publisher { pub fn recv_fetch(&mut self, msg: ietf::Fetch<'_>) -> Result<(), Error> { let subscribe_id = match msg.fetch_type { FetchType::Standalone { .. } => { - return self.control.send(ietf::FetchError { - request_id: msg.request_id, - error_code: 500, - reason_phrase: "not supported".into(), - }); + return self.send_fetch_error(msg.request_id, 500, "not supported"); } FetchType::RelativeJoining { subscriber_request_id, group_offset, } => { if group_offset != 0 { - return self.control.send(ietf::FetchError { - request_id: msg.request_id, - error_code: 500, - reason_phrase: "not supported".into(), - }); + return self.send_fetch_error(msg.request_id, 500, "not supported"); } subscriber_request_id } FetchType::AbsoluteJoining { .. } => { - return self.control.send(ietf::FetchError { - request_id: msg.request_id, - error_code: 500, - reason_phrase: "not supported".into(), - }); + return self.send_fetch_error(msg.request_id, 500, "not supported"); } }; let subscribes = self.subscribes.lock(); if !subscribes.contains_key(&subscribe_id) { - return self.control.send(ietf::FetchError { - request_id: msg.request_id, - error_code: 404, - reason_phrase: "Subscribe not found".into(), - }); + return self.send_fetch_error(msg.request_id, 404, "Subscribe not found"); } - self.control.send(ietf::FetchOk { - request_id: msg.request_id, - group_order: GroupOrder::Descending, - end_of_track: false, - // TODO get the proper group_id - end_location: Location { group: 0, object: 0 }, - })?; + self.send_fetch_ok(msg.request_id)?; let session = self.session.clone(); let request_id = msg.request_id; @@ -409,6 +405,38 @@ impl Publisher { Ok(()) } + /// Send a fetch OK, using RequestOk for v15. + fn send_fetch_ok(&self, request_id: RequestId) -> Result<(), Error> { + match self.version { + Version::Draft14 => self.control.send(ietf::FetchOk { + request_id, + group_order: GroupOrder::Descending, + end_of_track: false, + end_location: Location { group: 0, object: 0 }, + }), + Version::Draft15 => self.control.send(ietf::RequestOk { + request_id, + parameters: MessageParameters::default(), + }), + } + } + + /// Send a fetch error, using RequestError for v15. + fn send_fetch_error(&self, request_id: RequestId, error_code: u64, reason: &str) -> Result<(), Error> { + match self.version { + Version::Draft14 => self.control.send(ietf::FetchError { + request_id, + error_code, + reason_phrase: reason.into(), + }), + Version::Draft15 => self.control.send(ietf::RequestError { + request_id, + error_code, + reason_phrase: reason.into(), + }), + } + } + // We literally just create a stream and FIN it. async fn run_fetch(session: S, request_id: RequestId, version: Version) -> Result<(), Error> { let stream = session diff --git a/rs/moq-lite/src/ietf/request.rs b/rs/moq-lite/src/ietf/request.rs index 729cb6ca0..d13899bb3 100644 --- a/rs/moq-lite/src/ietf/request.rs +++ b/rs/moq-lite/src/ietf/request.rs @@ -1,6 +1,8 @@ +use std::borrow::Cow; + use crate::{ coding::{Decode, DecodeError, Encode}, - ietf::{Message, Version}, + ietf::{Message, MessageParameters, Version}, }; #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -68,3 +70,61 @@ impl Message for RequestsBlocked { Ok(Self { request_id }) } } + +/// REQUEST_OK (0x07 in v15) - Generic success response for any request. +/// Replaces PublishNamespaceOk, SubscribeNamespaceOk in v15. +/// Also used as response to SubscribeUpdate and TrackStatus in v15. +#[derive(Clone, Debug)] +pub struct RequestOk { + pub request_id: RequestId, + pub parameters: MessageParameters, +} + +impl Message for RequestOk { + const ID: u64 = 0x07; + + fn encode_msg(&self, w: &mut W, version: Version) { + self.request_id.encode(w, version); + self.parameters.encode(w, version); + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + let request_id = RequestId::decode(r, version)?; + let parameters = MessageParameters::decode(r, version)?; + Ok(Self { + request_id, + parameters, + }) + } +} + +/// REQUEST_ERROR (0x05 in v15) - Generic error response for any request. +/// Replaces SubscribeError, PublishError, PublishNamespaceError, +/// SubscribeNamespaceError, FetchError in v15. +#[derive(Clone, Debug)] +pub struct RequestError<'a> { + pub request_id: RequestId, + pub error_code: u64, + pub reason_phrase: Cow<'a, str>, +} + +impl Message for RequestError<'_> { + const ID: u64 = 0x05; + + fn encode_msg(&self, w: &mut W, version: Version) { + self.request_id.encode(w, version); + self.error_code.encode(w, version); + self.reason_phrase.encode(w, version); + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + let request_id = RequestId::decode(r, version)?; + let error_code = u64::decode(r, version)?; + let reason_phrase = Cow::::decode(r, version)?; + Ok(Self { + request_id, + error_code, + reason_phrase, + }) + } +} diff --git a/rs/moq-lite/src/ietf/session.rs b/rs/moq-lite/src/ietf/session.rs index 45d857ff9..715f53d54 100644 --- a/rs/moq-lite/src/ietf/session.rs +++ b/rs/moq-lite/src/ietf/session.rs @@ -62,7 +62,7 @@ async fn run( tokio::select! { res = subscriber.clone().run() => res, res = publisher.clone().run() => res, - res = run_control_read(setup.reader, control, publisher, subscriber) => res, + res = run_control_read(setup.reader, control, publisher, subscriber, version) => res, res = Control::run::(setup.writer, rx) => res, } } @@ -72,6 +72,7 @@ async fn run_control_read( control: Control, mut publisher: Publisher, mut subscriber: Subscriber, + version: Version, ) -> Result<(), Error> { loop { let id: u64 = match reader.decode_maybe().await? { @@ -87,122 +88,144 @@ async fn run_control_read( match id { ietf::Subscribe::ID => { - let msg = ietf::Subscribe::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::Subscribe::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_subscribe(msg)?; } ietf::SubscribeUpdate::ID => { - let msg = ietf::SubscribeUpdate::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::SubscribeUpdate::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_subscribe_update(msg)?; } ietf::SubscribeOk::ID => { - let msg = ietf::SubscribeOk::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::SubscribeOk::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_subscribe_ok(msg)?; } + // 0x05: SubscribeError in v14, REQUEST_ERROR in v15 ietf::SubscribeError::ID => { - let msg = ietf::SubscribeError::decode_msg(&mut data, ietf::Version::Draft14)?; - tracing::debug!(message = ?msg, "received control message"); - subscriber.recv_subscribe_error(msg)?; + match version { + Version::Draft14 => { + let msg = ietf::SubscribeError::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_subscribe_error(msg)?; + } + Version::Draft15 => { + let msg = ietf::RequestError::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_request_error(&msg)?; + publisher.recv_request_error(&msg)?; + } + } } ietf::PublishNamespace::ID => { - let msg = ietf::PublishNamespace::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::PublishNamespace::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_publish_namespace(msg)?; } + // 0x07: PublishNamespaceOk in v14, REQUEST_OK in v15 ietf::PublishNamespaceOk::ID => { - let msg = ietf::PublishNamespaceOk::decode_msg(&mut data, ietf::Version::Draft14)?; - tracing::debug!(message = ?msg, "received control message"); - publisher.recv_publish_namespace_ok(msg)?; + match version { + Version::Draft14 => { + let msg = ietf::PublishNamespaceOk::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + publisher.recv_publish_namespace_ok(msg)?; + } + Version::Draft15 => { + let msg = ietf::RequestOk::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_request_ok(&msg)?; + publisher.recv_request_ok(&msg)?; + } + } } ietf::PublishNamespaceError::ID => { - let msg = ietf::PublishNamespaceError::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::PublishNamespaceError::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_publish_namespace_error(msg)?; } ietf::PublishNamespaceDone::ID => { - let msg = ietf::PublishNamespaceDone::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::PublishNamespaceDone::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_publish_namespace_done(msg)?; } ietf::Unsubscribe::ID => { - let msg = ietf::Unsubscribe::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::Unsubscribe::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_unsubscribe(msg)?; } ietf::PublishDone::ID => { - let msg = ietf::PublishDone::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::PublishDone::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_publish_done(msg)?; } ietf::PublishNamespaceCancel::ID => { - let msg = ietf::PublishNamespaceCancel::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::PublishNamespaceCancel::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_publish_namespace_cancel(msg)?; } ietf::TrackStatus::ID => { - let msg = ietf::TrackStatus::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::TrackStatus::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_track_status(msg)?; } ietf::GoAway::ID => { - let msg = ietf::GoAway::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::GoAway::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); return Err(Error::Unsupported); } ietf::SubscribeNamespace::ID => { - let msg = ietf::SubscribeNamespace::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::SubscribeNamespace::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_subscribe_namespace(msg)?; } ietf::SubscribeNamespaceOk::ID => { - let msg = ietf::SubscribeNamespaceOk::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::SubscribeNamespaceOk::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_subscribe_namespace_ok(msg)?; } ietf::SubscribeNamespaceError::ID => { - let msg = ietf::SubscribeNamespaceError::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::SubscribeNamespaceError::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_subscribe_namespace_error(msg)?; } ietf::UnsubscribeNamespace::ID => { - let msg = ietf::UnsubscribeNamespace::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::UnsubscribeNamespace::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_unsubscribe_namespace(msg)?; } ietf::MaxRequestId::ID => { - let msg = ietf::MaxRequestId::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::MaxRequestId::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); control.max_request_id(msg.request_id); } ietf::RequestsBlocked::ID => { - let msg = ietf::RequestsBlocked::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::RequestsBlocked::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); tracing::warn!(?msg, "ignoring requests blocked"); } ietf::Fetch::ID => { - let msg = ietf::Fetch::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::Fetch::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_fetch(msg)?; } ietf::FetchCancel::ID => { - let msg = ietf::FetchCancel::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::FetchCancel::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); publisher.recv_fetch_cancel(msg)?; } ietf::FetchOk::ID => { - let msg = ietf::FetchOk::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::FetchOk::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_fetch_ok(msg)?; } ietf::FetchError::ID => { - let msg = ietf::FetchError::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::FetchError::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_fetch_error(msg)?; } ietf::Publish::ID => { - let msg = ietf::Publish::decode_msg(&mut data, ietf::Version::Draft14)?; + let msg = ietf::Publish::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_publish(msg)?; } diff --git a/rs/moq-lite/src/ietf/setup.rs b/rs/moq-lite/src/ietf/setup.rs index 27171d343..b51e38829 100644 --- a/rs/moq-lite/src/ietf/setup.rs +++ b/rs/moq-lite/src/ietf/setup.rs @@ -18,23 +18,42 @@ impl Message for ClientSetup { /// Decode a client setup message. fn decode_msg(r: &mut R, version: IetfVersion) -> Result { - let versions = Versions::decode(r, version)?; - let parameters = Parameters::decode(r, version)?; - - Ok(Self { versions, parameters }) + match version { + IetfVersion::Draft14 => { + let versions = Versions::decode(r, version)?; + let parameters = Parameters::decode(r, version)?; + Ok(Self { versions, parameters }) + } + IetfVersion::Draft15 => { + // Draft15: no versions list, just parameters + let parameters = Parameters::decode(r, version)?; + Ok(Self { + versions: vec![Version(IetfVersion::Draft15 as u64)].into(), + parameters, + }) + } + } } /// Encode a client setup message. fn encode_msg(&self, w: &mut W, version: IetfVersion) { - self.versions.encode(w, version); - self.parameters.encode(w, version); + match version { + IetfVersion::Draft14 => { + self.versions.encode(w, version); + self.parameters.encode(w, version); + } + IetfVersion::Draft15 => { + // Draft15: no versions list, just parameters + self.parameters.encode(w, version); + } + } } } /// Sent by the server in response to a client setup. #[derive(Debug, Clone)] pub struct ServerSetup { - /// The list of supported versions in preferred order. + /// The selected version. pub version: Version, /// Supported extensions. @@ -45,14 +64,33 @@ impl Message for ServerSetup { const ID: u64 = 0x21; fn encode_msg(&self, w: &mut W, version: IetfVersion) { - self.version.encode(w, version); - self.parameters.encode(w, version); + match version { + IetfVersion::Draft14 => { + self.version.encode(w, version); + self.parameters.encode(w, version); + } + IetfVersion::Draft15 => { + // Draft15: no version field, just parameters + self.parameters.encode(w, version); + } + } } fn decode_msg(r: &mut R, version: IetfVersion) -> Result { - let version = Version::decode(r, version)?; - let parameters = Parameters::decode(r, version)?; - - Ok(Self { version, parameters }) + match version { + IetfVersion::Draft14 => { + let version = Version::decode(r, version)?; + let parameters = Parameters::decode(r, version)?; + Ok(Self { version, parameters }) + } + IetfVersion::Draft15 => { + // Draft15: no version field, just parameters + let parameters = Parameters::decode(r, version)?; + Ok(Self { + version: Version(IetfVersion::Draft15 as u64), + parameters, + }) + } + } } } diff --git a/rs/moq-lite/src/ietf/subscribe.rs b/rs/moq-lite/src/ietf/subscribe.rs index b552b2535..f88799bd5 100644 --- a/rs/moq-lite/src/ietf/subscribe.rs +++ b/rs/moq-lite/src/ietf/subscribe.rs @@ -1,4 +1,4 @@ -//! IETF moq-transport-14 subscribe messages +//! IETF moq-transport subscribe messages (v14 + v15) use std::borrow::Cow; @@ -7,7 +7,7 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; use crate::{ Path, coding::*, - ietf::{GroupOrder, Location, Message, Parameters, RequestId, Version}, + ietf::{GroupOrder, Location, Message, MessageParameters, Parameters, RequestId, Version}, }; use super::namespace::{decode_namespace, encode_namespace}; @@ -50,60 +50,93 @@ impl Message for Subscribe<'_> { fn decode_msg(r: &mut R, version: Version) -> Result { let request_id = RequestId::decode(r, version)?; - - // Decode namespace (tuple of strings) let track_namespace = decode_namespace(r, version)?; - let track_name = Cow::::decode(r, version)?; - let subscriber_priority = u8::decode(r, version)?; - - let group_order = GroupOrder::decode(r, version)?; - - let forward = bool::decode(r, version)?; - if !forward { - return Err(DecodeError::Unsupported); - } - let filter_type = FilterType::decode(r, version)?; - match filter_type { - FilterType::AbsoluteStart => { - let _start = Location::decode(r, version)?; + match version { + Version::Draft14 => { + let subscriber_priority = u8::decode(r, version)?; + let group_order = GroupOrder::decode(r, version)?; + + let forward = bool::decode(r, version)?; + if !forward { + return Err(DecodeError::Unsupported); + } + + let filter_type = FilterType::decode(r, version)?; + match filter_type { + FilterType::AbsoluteStart => { + let _start = Location::decode(r, version)?; + } + FilterType::AbsoluteRange => { + let _start = Location::decode(r, version)?; + let _end_group = u64::decode(r, version)?; + } + FilterType::NextGroup | FilterType::LargestObject => {} + }; + + let _params = Parameters::decode(r, version)?; + + Ok(Self { + request_id, + track_namespace, + track_name, + subscriber_priority, + group_order, + filter_type, + }) } - FilterType::AbsoluteRange => { - let _start = Location::decode(r, version)?; - let _end_group = u64::decode(r, version)?; + Version::Draft15 => { + // v15: fields moved into parameters + let params = MessageParameters::decode(r, version)?; + + let subscriber_priority = params.subscriber_priority().unwrap_or(128); + let group_order = match params.group_order() { + Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + None => GroupOrder::Descending, + }; + let filter_type = params.subscription_filter().unwrap_or(FilterType::LargestObject); + + Ok(Self { + request_id, + track_namespace, + track_name, + subscriber_priority, + group_order, + filter_type, + }) } - FilterType::NextGroup | FilterType::LargestObject => {} - }; - - // Ignore parameters, who cares. - let _params = Parameters::decode(r, version)?; - - Ok(Self { - request_id, - track_namespace, - track_name, - subscriber_priority, - group_order, - filter_type, - }) + } } fn encode_msg(&self, w: &mut W, version: Version) { self.request_id.encode(w, version); encode_namespace(w, &self.track_namespace, version); self.track_name.encode(w, version); - self.subscriber_priority.encode(w, version); - GroupOrder::Descending.encode(w, version); - true.encode(w, version); // forward - assert!( - !matches!(self.filter_type, FilterType::AbsoluteStart | FilterType::AbsoluteRange), - "Absolute subscribe not supported" - ); + match version { + Version::Draft14 => { + self.subscriber_priority.encode(w, version); + GroupOrder::Descending.encode(w, version); + true.encode(w, version); // forward - self.filter_type.encode(w, version); - 0u8.encode(w, version); // no parameters + assert!( + !matches!(self.filter_type, FilterType::AbsoluteStart | FilterType::AbsoluteRange), + "Absolute subscribe not supported" + ); + + self.filter_type.encode(w, version); + 0u8.encode(w, version); // no parameters + } + Version::Draft15 => { + let mut params = MessageParameters::default(); + params.set_subscriber_priority(self.subscriber_priority); + params.set_group_order(u8::from(GroupOrder::Descending) as u64); + params.set_forward(true); + params.set_subscription_filter(self.filter_type); + params.encode(w, version); + } + } } } @@ -120,32 +153,47 @@ impl Message for SubscribeOk { fn encode_msg(&self, w: &mut W, version: Version) { self.request_id.encode(w, version); self.track_alias.encode(w, version); - 0u64.encode(w, version); // expires = 0 - GroupOrder::Descending.encode(w, version); - false.encode(w, version); // no content - 0u8.encode(w, version); // no parameters + + match version { + Version::Draft14 => { + 0u64.encode(w, version); // expires = 0 + GroupOrder::Descending.encode(w, version); + false.encode(w, version); // no content + 0u8.encode(w, version); // no parameters + } + Version::Draft15 => { + // v15: just parameters after track_alias + let mut params = MessageParameters::default(); + params.set_group_order(u8::from(GroupOrder::Descending) as u64); + params.encode(w, version); + } + } } fn decode_msg(r: &mut R, version: Version) -> Result { let request_id = RequestId::decode(r, version)?; let track_alias = u64::decode(r, version)?; - let expires = u64::decode(r, version)?; - if expires != 0 { - return Err(DecodeError::Unsupported); - } + match version { + Version::Draft14 => { + let expires = u64::decode(r, version)?; + if expires != 0 { + return Err(DecodeError::Unsupported); + } - // Ignore group order, who cares. - let _group_order = u8::decode(r, version)?; + let _group_order = u8::decode(r, version)?; - // TODO: We don't support largest group/object yet - if bool::decode(r, version)? { - let _group = u64::decode(r, version)?; - let _object = u64::decode(r, version)?; - } + if bool::decode(r, version)? { + let _group = u64::decode(r, version)?; + let _object = u64::decode(r, version)?; + } - // Ignore parameters, who cares. - let _params = Parameters::decode(r, version)?; + let _params = Parameters::decode(r, version)?; + } + Version::Draft15 => { + let _params = MessageParameters::decode(r, version)?; + } + } Ok(Self { request_id, @@ -202,18 +250,7 @@ impl Message for Unsubscribe { } } -/* - Type (i) = 0x2, - Length (16), - Request ID (i), - Subscription Request ID (i), - Start Location (Location), - End Group (i), - Subscriber Priority (8), - Forward (8), - Number of Parameters (i), - Parameters (..) ... -*/ +/// SubscribeUpdate message (0x02) #[derive(Debug)] pub struct SubscribeUpdate { pub request_id: RequestId, @@ -222,39 +259,72 @@ pub struct SubscribeUpdate { pub end_group: u64, pub subscriber_priority: u8, pub forward: bool, - // pub parameters: Parameters, } impl Message for SubscribeUpdate { const ID: u64 = 0x02; fn encode_msg(&self, w: &mut W, version: Version) { - self.request_id.encode(w, version); - self.subscription_request_id.encode(w, version); - self.start_location.encode(w, version); - self.end_group.encode(w, version); - self.subscriber_priority.encode(w, version); - self.forward.encode(w, version); - 0u8.encode(w, version); // no parameters + match version { + Version::Draft14 => { + self.request_id.encode(w, version); + self.subscription_request_id.encode(w, version); + self.start_location.encode(w, version); + self.end_group.encode(w, version); + self.subscriber_priority.encode(w, version); + self.forward.encode(w, version); + 0u8.encode(w, version); // no parameters + } + Version::Draft15 => { + self.request_id.encode(w, version); + self.subscription_request_id.encode(w, version); + let mut params = MessageParameters::default(); + params.set_subscriber_priority(self.subscriber_priority); + params.set_forward(self.forward); + params.set_subscription_filter(FilterType::LargestObject); + params.encode(w, version); + } + } } fn decode_msg(r: &mut R, version: Version) -> Result { - let request_id = RequestId::decode(r, version)?; - let subscription_request_id = RequestId::decode(r, version)?; - let start_location = Location::decode(r, version)?; - let end_group = u64::decode(r, version)?; - let subscriber_priority = u8::decode(r, version)?; - let forward = bool::decode(r, version)?; - let _parameters = Parameters::decode(r, version)?; - - Ok(Self { - request_id, - subscription_request_id, - start_location, - end_group, - subscriber_priority, - forward, - }) + match version { + Version::Draft14 => { + let request_id = RequestId::decode(r, version)?; + let subscription_request_id = RequestId::decode(r, version)?; + let start_location = Location::decode(r, version)?; + let end_group = u64::decode(r, version)?; + let subscriber_priority = u8::decode(r, version)?; + let forward = bool::decode(r, version)?; + let _parameters = Parameters::decode(r, version)?; + + Ok(Self { + request_id, + subscription_request_id, + start_location, + end_group, + subscriber_priority, + forward, + }) + } + Version::Draft15 => { + let request_id = RequestId::decode(r, version)?; + let subscription_request_id = RequestId::decode(r, version)?; + let params = MessageParameters::decode(r, version)?; + + let subscriber_priority = params.subscriber_priority().unwrap_or(128); + let forward = params.forward().unwrap_or(true); + + Ok(Self { + request_id, + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority, + forward, + }) + } + } } } @@ -263,15 +333,15 @@ mod tests { use super::*; use bytes::BytesMut; - fn encode_message(msg: &M) -> Vec { + fn encode_message(msg: &M, version: Version) -> Vec { let mut buf = BytesMut::new(); - msg.encode_msg(&mut buf, Version::Draft14); + msg.encode_msg(&mut buf, version); buf.to_vec() } - fn decode_message(bytes: &[u8]) -> Result { + fn decode_message(bytes: &[u8], version: Version) -> Result { let mut buf = bytes::Bytes::from(bytes.to_vec()); - M::decode_msg(&mut buf, Version::Draft14) + M::decode_msg(&mut buf, version) } #[test] @@ -285,8 +355,28 @@ mod tests { filter_type: FilterType::LargestObject, }; - let encoded = encode_message(&msg); - let decoded: Subscribe = decode_message(&encoded).unwrap(); + let encoded = encode_message(&msg, Version::Draft14); + let decoded: Subscribe = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(1)); + assert_eq!(decoded.track_namespace.as_str(), "test"); + assert_eq!(decoded.track_name, "video"); + assert_eq!(decoded.subscriber_priority, 128); + } + + #[test] + fn test_subscribe_round_trip_v15() { + let msg = Subscribe { + request_id: RequestId(1), + track_namespace: Path::new("test"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter_type: FilterType::LargestObject, + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: Subscribe = decode_message(&encoded, Version::Draft15).unwrap(); assert_eq!(decoded.request_id, RequestId(1)); assert_eq!(decoded.track_namespace.as_str(), "test"); @@ -305,8 +395,8 @@ mod tests { filter_type: FilterType::LargestObject, }; - let encoded = encode_message(&msg); - let decoded: Subscribe = decode_message(&encoded).unwrap(); + let encoded = encode_message(&msg, Version::Draft14); + let decoded: Subscribe = decode_message(&encoded, Version::Draft14).unwrap(); assert_eq!(decoded.track_namespace.as_str(), "conference/room123"); } @@ -318,10 +408,24 @@ mod tests { track_alias: 42, }; - let encoded = encode_message(&msg); - let decoded: SubscribeOk = decode_message(&encoded).unwrap(); + let encoded = encode_message(&msg, Version::Draft14); + let decoded: SubscribeOk = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(42)); + } + + #[test] + fn test_subscribe_ok_v15() { + let msg = SubscribeOk { + request_id: RequestId(42), + track_alias: 42, + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: SubscribeOk = decode_message(&encoded, Version::Draft15).unwrap(); assert_eq!(decoded.request_id, RequestId(42)); + assert_eq!(decoded.track_alias, 42); } #[test] @@ -332,8 +436,8 @@ mod tests { reason_phrase: "Not found".into(), }; - let encoded = encode_message(&msg); - let decoded: SubscribeError = decode_message(&encoded).unwrap(); + let encoded = encode_message(&msg, Version::Draft14); + let decoded: SubscribeError = decode_message(&encoded, Version::Draft14).unwrap(); assert_eq!(decoded.request_id, RequestId(123)); assert_eq!(decoded.error_code, 500); @@ -346,8 +450,8 @@ mod tests { request_id: RequestId(999), }; - let encoded = encode_message(&msg); - let decoded: Unsubscribe = decode_message(&encoded).unwrap(); + let encoded = encode_message(&msg, Version::Draft14); + let decoded: Unsubscribe = decode_message(&encoded, Version::Draft14).unwrap(); assert_eq!(decoded.request_id, RequestId(999)); } @@ -367,7 +471,7 @@ mod tests { 0x00, // num_params ]; - let result: Result = decode_message(&invalid_bytes); + let result: Result = decode_message(&invalid_bytes, Version::Draft14); assert!(result.is_err()); } @@ -382,7 +486,7 @@ mod tests { 0x00, // num_params ]; - let result: Result = decode_message(&invalid_bytes); + let result: Result = decode_message(&invalid_bytes, Version::Draft14); assert!(result.is_err()); } } diff --git a/rs/moq-lite/src/ietf/subscriber.rs b/rs/moq-lite/src/ietf/subscriber.rs index 5354aa169..4f25acc21 100644 --- a/rs/moq-lite/src/ietf/subscriber.rs +++ b/rs/moq-lite/src/ietf/subscriber.rs @@ -7,7 +7,7 @@ use crate::{ Broadcast, Error, Frame, FrameProducer, Group, GroupProducer, OriginProducer, Path, PathOwned, Track, TrackProducer, coding::Reader, - ietf::{self, Control, FetchHeader, FilterType, GroupFlags, GroupOrder, RequestId, Version}, + ietf::{self, Control, FetchHeader, FilterType, GroupFlags, GroupOrder, MessageParameters, RequestId, Version}, model::BroadcastProducer, }; @@ -66,11 +66,34 @@ impl Subscriber { let request_id = msg.request_id; match self.start_announce(msg.track_namespace.to_owned()) { - Ok(_) => self.control.send(ietf::PublishNamespaceOk { request_id }), - Err(err) => self.control.send(ietf::PublishNamespaceError { + Ok(_) => self.send_ok(request_id), + Err(err) => self.send_error(request_id, 400, &err.to_string()), + } + } + + /// Send a generic OK response, using the version-appropriate message. + fn send_ok(&self, request_id: RequestId) -> Result<(), Error> { + match self.version { + Version::Draft14 => self.control.send(ietf::PublishNamespaceOk { request_id }), + Version::Draft15 => self.control.send(ietf::RequestOk { + request_id, + parameters: MessageParameters::default(), + }), + } + } + + /// Send a generic error response, using the version-appropriate message. + fn send_error(&self, request_id: RequestId, error_code: u64, reason: &str) -> Result<(), Error> { + match self.version { + Version::Draft14 => self.control.send(ietf::PublishNamespaceError { request_id, - error_code: 400, - reason_phrase: err.to_string().into(), + error_code, + reason_phrase: reason.into(), + }), + Version::Draft15 => self.control.send(ietf::RequestError { + request_id, + error_code, + reason_phrase: reason.into(), }), } } @@ -162,6 +185,26 @@ impl Subscriber { Ok(()) } + pub fn recv_request_ok(&mut self, _msg: &ietf::RequestOk) -> Result<(), Error> { + // v15: generic OK response. SubscribeOk is still separate (0x04). + // Other request types (publish_namespace, fetch) are no-ops for us. + Ok(()) + } + + pub fn recv_request_error(&mut self, msg: &ietf::RequestError<'_>) -> Result<(), Error> { + // v15: generic error response. Check if it's a subscribe error. + let mut state = self.state.lock(); + + if let Some(track) = state.subscribes.remove(&msg.request_id) { + track.producer.abort(Error::Cancel); + if let Some(alias) = track.alias { + state.aliases.remove(&alias); + } + } + + Ok(()) + } + pub fn recv_publish_done(&mut self, msg: ietf::PublishDone<'_>) -> Result<(), Error> { let mut state = self.state.lock(); @@ -204,7 +247,7 @@ impl Subscriber { match kind { FetchHeader::TYPE => return Err(Error::Unsupported), - GroupFlags::START..=GroupFlags::END => {} + GroupFlags::START..=GroupFlags::END | GroupFlags::START_NO_PRIORITY..=GroupFlags::END_NO_PRIORITY => {} _ => return Err(Error::UnexpectedStream), } @@ -420,19 +463,40 @@ impl Subscriber { pub fn recv_publish(&mut self, msg: ietf::Publish<'_>) -> Result<(), Error> { if let Err(err) = self.start_publish(&msg) { - self.control.send(ietf::PublishError { - request_id: msg.request_id, - error_code: 400, - reason_phrase: err.to_string().into(), - })?; + match self.version { + Version::Draft14 => { + self.control.send(ietf::PublishError { + request_id: msg.request_id, + error_code: 400, + reason_phrase: err.to_string().into(), + })?; + } + Version::Draft15 => { + self.control.send(ietf::RequestError { + request_id: msg.request_id, + error_code: 400, + reason_phrase: err.to_string().into(), + })?; + } + } } else { - self.control.send(ietf::PublishOk { - request_id: msg.request_id, - forward: true, - subscriber_priority: 0, - group_order: GroupOrder::Descending, - filter_type: FilterType::LargestObject, - })?; + match self.version { + Version::Draft14 => { + self.control.send(ietf::PublishOk { + request_id: msg.request_id, + forward: true, + subscriber_priority: 0, + group_order: GroupOrder::Descending, + filter_type: FilterType::LargestObject, + })?; + } + Version::Draft15 => { + self.control.send(ietf::RequestOk { + request_id: msg.request_id, + parameters: MessageParameters::default(), + })?; + } + } } Ok(()) diff --git a/rs/moq-lite/src/ietf/track.rs b/rs/moq-lite/src/ietf/track.rs index 24338afee..50809faa3 100644 --- a/rs/moq-lite/src/ietf/track.rs +++ b/rs/moq-lite/src/ietf/track.rs @@ -1,4 +1,4 @@ -//! IETF moq-transport-14 track status messages +//! IETF moq-transport track status messages (v14 + v15) use std::borrow::Cow; @@ -7,12 +7,14 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; use crate::{ Path, coding::*, - ietf::{FilterType, GroupOrder, Message, Parameters, RequestId, Version}, + ietf::{FilterType, GroupOrder, Message, MessageParameters, Parameters, RequestId, Version}, }; use super::namespace::{decode_namespace, encode_namespace}; /// TrackStatus message (0x0d) +/// v14: own format (TrackStatusRequest-like with subscribe fields) +/// v15: same wire format as SUBSCRIBE. Response is REQUEST_OK. #[derive(Clone, Debug)] pub struct TrackStatus<'a> { pub request_id: RequestId, @@ -27,11 +29,21 @@ impl Message for TrackStatus<'_> { self.request_id.encode(w, version); encode_namespace(w, &self.track_namespace, version); self.track_name.encode(w, version); - 0u8.encode(w, version); // subscriber priority - GroupOrder::Descending.encode(w, version); - false.encode(w, version); // forward - FilterType::LargestObject.encode(w, version); // filter type - 0u8.encode(w, version); // no parameters + + match version { + Version::Draft14 => { + 0u8.encode(w, version); // subscriber priority + GroupOrder::Descending.encode(w, version); + false.encode(w, version); // forward + FilterType::LargestObject.encode(w, version); // filter type + 0u8.encode(w, version); // no parameters + } + Version::Draft15 => { + // v15: same format as Subscribe - fields in parameters + let params = MessageParameters::default(); + params.encode(w, version); + } + } } fn decode_msg(r: &mut R, version: Version) -> Result { @@ -39,13 +51,18 @@ impl Message for TrackStatus<'_> { let track_namespace = decode_namespace(r, version)?; let track_name = Cow::::decode(r, version)?; - let _subscriber_priority = u8::decode(r, version)?; - let _group_order = GroupOrder::decode(r, version)?; - let _forward = bool::decode(r, version)?; - let _filter_type = u64::decode(r, version)?; - - // Ignore parameters, who cares. - let _params = Parameters::decode(r, version)?; + match version { + Version::Draft14 => { + let _subscriber_priority = u8::decode(r, version)?; + let _group_order = GroupOrder::decode(r, version)?; + let _forward = bool::decode(r, version)?; + let _filter_type = u64::decode(r, version)?; + let _params = Parameters::decode(r, version)?; + } + Version::Draft15 => { + let _params = MessageParameters::decode(r, version)?; + } + } Ok(Self { request_id, diff --git a/rs/moq-lite/src/ietf/version.rs b/rs/moq-lite/src/ietf/version.rs index 8a833deca..1f445fd27 100644 --- a/rs/moq-lite/src/ietf/version.rs +++ b/rs/moq-lite/src/ietf/version.rs @@ -1,11 +1,13 @@ use crate::coding; pub const ALPN: &str = "moq-00"; +pub const ALPN_15: &str = "moqt-15"; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] #[repr(u64)] pub enum Version { Draft14 = 0xff00000e, + Draft15 = 0xff00000f, } impl TryFrom for Version { @@ -14,6 +16,8 @@ impl TryFrom for Version { fn try_from(value: coding::Version) -> Result { if value == Self::Draft14.into() { Ok(Self::Draft14) + } else if value == Self::Draft15.into() { + Ok(Self::Draft15) } else { Err(()) } From bfb352a6a767d3cdb0cffba7283b7faac3186569 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Feb 2026 16:52:09 -0800 Subject: [PATCH 2/7] Initial JS implementation. --- js/lite/src/connection/connect.ts | 15 ++- js/lite/src/ietf/connection.ts | 17 +++- js/lite/src/ietf/control.ts | 61 +++++++++--- js/lite/src/ietf/ietf.test.ts | 77 ++++++++++++--- js/lite/src/ietf/index.ts | 2 + js/lite/src/ietf/object.ts | 34 +++++-- js/lite/src/ietf/parameters.ts | 141 +++++++++++++++++++++++++++ js/lite/src/ietf/publish.ts | 91 +++++++++++------- js/lite/src/ietf/publisher.ts | 12 +++ js/lite/src/ietf/request.ts | 71 ++++++++++++++ js/lite/src/ietf/setup.ts | 110 +++++++++++++-------- js/lite/src/ietf/subscribe.ts | 152 +++++++++++++++++++----------- js/lite/src/ietf/subscriber.ts | 19 ++++ js/lite/src/ietf/version.ts | 12 +++ rs/moq-lite/src/ietf/group.rs | 6 +- rs/moq-lite/src/ietf/publish.rs | 3 +- rs/moq-lite/src/ietf/publisher.rs | 4 +- rs/moq-lite/src/ietf/request.rs | 5 +- rs/moq-lite/src/ietf/session.rs | 52 +++++----- 19 files changed, 678 insertions(+), 206 deletions(-) diff --git a/js/lite/src/connection/connect.ts b/js/lite/src/connection/connect.ts index 3bf642d1e..2b65bd70f 100644 --- a/js/lite/src/connection/connect.ts +++ b/js/lite/src/connection/connect.ts @@ -82,9 +82,13 @@ export async function connect(url: URL, props?: ConnectProps): Promise { this.#control.close(); @@ -203,6 +206,14 @@ export class Connection implements Established { this.#control.maxRequestId(msg.requestId); } else if (msg instanceof RequestsBlocked) { console.warn("ignoring REQUESTS_BLOCKED message"); + } else if (msg instanceof RequestOk) { + // v15: Route RequestOk to both publisher and subscriber + await this.#publisher.handleRequestOk(msg); + await this.#subscriber.handleRequestOk(msg); + } else if (msg instanceof RequestError) { + // v15: Route RequestError to both publisher and subscriber + await this.#publisher.handleRequestError(msg); + await this.#subscriber.handleRequestError(msg); } else { unreachable(msg); } diff --git a/js/lite/src/ietf/control.ts b/js/lite/src/ietf/control.ts index 4f28b0c6a..9e3dbab51 100644 --- a/js/lite/src/ietf/control.ts +++ b/js/lite/src/ietf/control.ts @@ -1,5 +1,5 @@ import { Mutex } from "async-mutex"; -import type { Stream as StreamInner } from "../stream.ts"; +import type { Reader, Stream as StreamInner, Writer } from "../stream.ts"; import { Fetch, FetchCancel, FetchError, FetchOk } from "./fetch.ts"; import { GoAway } from "./goaway.ts"; import { Publish, PublishDone, PublishError, PublishOk } from "./publish.ts"; @@ -10,7 +10,7 @@ import { PublishNamespaceError, PublishNamespaceOk, } from "./publish_namespace.ts"; -import { MaxRequestId, RequestsBlocked } from "./request.ts"; +import { MaxRequestId, RequestError, RequestOk, RequestsBlocked } from "./request.ts"; import * as Setup from "./setup.ts"; import { Subscribe, SubscribeError, SubscribeOk, Unsubscribe } from "./subscribe.ts"; import { @@ -20,11 +20,10 @@ import { UnsubscribeNamespace, } from "./subscribe_namespace.ts"; import { TrackStatus, TrackStatusRequest } from "./track.ts"; +import { type IetfVersion, Version } from "./version.ts"; -/** - * Control message types as defined in moq-transport-14 - */ -const Messages = { +// v14 message map — IDs that have different meanings in v15 are handled specially +const MessagesV14 = { [Setup.ClientSetup.id]: Setup.ClientSetup, [Setup.ServerSetup.id]: Setup.ServerSetup, [Subscribe.id]: Subscribe, @@ -55,15 +54,42 @@ const Messages = { [RequestsBlocked.id]: RequestsBlocked, } as const; -export type MessageId = keyof typeof Messages; +// v15 message map — 0x05 → RequestError, 0x07 → RequestOk (different wire format) +// Messages removed in v15 (0x08, 0x0E, 0x12, 0x13, 0x19, 0x1F) are still kept for decoding compatibility +const MessagesV15 = { + [Setup.ClientSetup.id]: Setup.ClientSetup, + [Setup.ServerSetup.id]: Setup.ServerSetup, + [Subscribe.id]: Subscribe, + [SubscribeOk.id]: SubscribeOk, + [RequestError.id]: RequestError, // 0x05 → RequestError instead of SubscribeError + [PublishNamespace.id]: PublishNamespace, + [RequestOk.id]: RequestOk, // 0x07 → RequestOk instead of PublishNamespaceOk + [PublishNamespaceDone.id]: PublishNamespaceDone, + [Unsubscribe.id]: Unsubscribe, + [PublishDone.id]: PublishDone, + [PublishNamespaceCancel.id]: PublishNamespaceCancel, + [TrackStatusRequest.id]: TrackStatusRequest, + [GoAway.id]: GoAway, + [Fetch.id]: Fetch, + [FetchCancel.id]: FetchCancel, + [FetchOk.id]: FetchOk, + [SubscribeNamespace.id]: SubscribeNamespace, + [UnsubscribeNamespace.id]: UnsubscribeNamespace, + [Publish.id]: Publish, + [MaxRequestId.id]: MaxRequestId, + [RequestsBlocked.id]: RequestsBlocked, +} as const; -export type MessageType = (typeof Messages)[keyof typeof Messages]; +type V14MessageType = (typeof MessagesV14)[keyof typeof MessagesV14]; +type V15MessageType = (typeof MessagesV15)[keyof typeof MessagesV15]; +type MessageType = V14MessageType | V15MessageType; // Type for control message instances (not constructors) export type Message = InstanceType; export class Stream { stream: StreamInner; + version: IetfVersion; // The client always starts at 0. #requestId = 0n; @@ -76,8 +102,9 @@ export class Stream { #writeLock = new Mutex(); #readLock = new Mutex(); - constructor(stream: StreamInner, maxRequestId: bigint) { + constructor(stream: StreamInner, maxRequestId: bigint, version: IetfVersion = Version.DRAFT_14) { this.stream = stream; + this.version = version; this.#maxRequestId = maxRequestId; this.#maxRequestIdPromise = new Promise((resolve) => { this.#maxRequestIdResolve = resolve; @@ -96,7 +123,8 @@ export class Stream { await this.stream.writer.u53((message.constructor as MessageType).id); // Write message payload with u16 size prefix - await message.encode(this.stream.writer); + // Extra version arg is silently ignored by messages that don't need it + await (message.encode as (w: Writer, v?: IetfVersion) => Promise)(this.stream.writer, this.version); }); } @@ -107,12 +135,21 @@ export class Stream { async read(): Promise { return await this.#readLock.runExclusive(async () => { const messageType = await this.stream.reader.u53(); - if (!(messageType in Messages)) { + + const messages = this.version === Version.DRAFT_15 ? MessagesV15 : MessagesV14; + if (!(messageType in messages)) { throw new Error(`Unknown control message type: ${messageType}`); } try { - const msg = await Messages[messageType].decode(this.stream.reader); + const msgClass = messages[messageType as keyof typeof messages]; + + // Extra version arg is silently ignored by messages that don't need it + const msg = await (msgClass as { decode: (r: Reader, v?: IetfVersion) => Promise }).decode( + this.stream.reader, + this.version, + ); + console.debug("message read", msg); return msg; } catch (err) { diff --git a/js/lite/src/ietf/ietf.test.ts b/js/lite/src/ietf/ietf.test.ts index 85fc93598..a41c395cc 100644 --- a/js/lite/src/ietf/ietf.test.ts +++ b/js/lite/src/ietf/ietf.test.ts @@ -7,6 +7,7 @@ import { PublishDone } from "./publish.ts"; import * as Announce from "./publish_namespace.ts"; import * as Subscribe from "./subscribe.ts"; import * as Track from "./track.ts"; +import { type IetfVersion, Version } from "./version.ts"; // Helper to create a writable stream that captures written data function createTestWritableStream(): { stream: WritableStream; written: Uint8Array[] } { @@ -31,7 +32,7 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array { return result; } -// Helper to encode a message +// Helper to encode a message (no version) async function encodeMessage }>(message: T): Promise { const { stream, written } = createTestWritableStream(); const writer = new Writer(stream); @@ -41,18 +42,41 @@ async function encodeMessage }>(mes return concatChunks(written); } +// Helper to encode a versioned message +async function encodeVersioned }>( + message: T, + version: IetfVersion, +): Promise { + const { stream, written } = createTestWritableStream(); + const writer = new Writer(stream); + await message.encode(writer, version); + writer.close(); + await writer.closed; + return concatChunks(written); +} + // Helper to decode a message async function decodeMessage(bytes: Uint8Array, decoder: (r: Reader) => Promise): Promise { const reader = new Reader(undefined, bytes); return await decoder(reader); } -// Subscribe tests -test("Subscribe: round trip", async () => { +// Helper to decode a versioned message +async function decodeVersioned( + bytes: Uint8Array, + decoder: (r: Reader, v: IetfVersion) => Promise, + version: IetfVersion, +): Promise { + const reader = new Reader(undefined, bytes); + return await decoder(reader, version); +} + +// Subscribe tests (v14) +test("Subscribe v14: round trip", async () => { const msg = new Subscribe.Subscribe(1n, Path.from("test"), "video", 128); - const encoded = await encodeMessage(msg); - const decoded = await decodeMessage(encoded, Subscribe.Subscribe.decode); + const encoded = await encodeVersioned(msg, Version.DRAFT_14); + const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_14); assert.strictEqual(decoded.requestId, 1n); assert.strictEqual(decoded.trackNamespace, "test"); @@ -60,20 +84,43 @@ test("Subscribe: round trip", async () => { assert.strictEqual(decoded.subscriberPriority, 128); }); -test("Subscribe: nested namespace", async () => { +test("Subscribe v14: nested namespace", async () => { const msg = new Subscribe.Subscribe(100n, Path.from("conference/room123"), "audio", 255); - const encoded = await encodeMessage(msg); - const decoded = await decodeMessage(encoded, Subscribe.Subscribe.decode); + const encoded = await encodeVersioned(msg, Version.DRAFT_14); + const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_14); assert.strictEqual(decoded.trackNamespace, "conference/room123"); }); -test("SubscribeOk: without largest", async () => { +test("SubscribeOk v14: without largest", async () => { const msg = new Subscribe.SubscribeOk(42n, 43n); - const encoded = await encodeMessage(msg); - const decoded = await decodeMessage(encoded, Subscribe.SubscribeOk.decode); + const encoded = await encodeVersioned(msg, Version.DRAFT_14); + const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, Version.DRAFT_14); + + assert.strictEqual(decoded.requestId, 42n); + assert.strictEqual(decoded.trackAlias, 43n); +}); + +// Subscribe tests (v15) +test("Subscribe v15: round trip", async () => { + const msg = new Subscribe.Subscribe(1n, Path.from("test"), "video", 128); + + const encoded = await encodeVersioned(msg, Version.DRAFT_15); + const decoded = await decodeVersioned(encoded, Subscribe.Subscribe.decode, Version.DRAFT_15); + + assert.strictEqual(decoded.requestId, 1n); + assert.strictEqual(decoded.trackNamespace, "test"); + assert.strictEqual(decoded.trackName, "video"); + assert.strictEqual(decoded.subscriberPriority, 128); +}); + +test("SubscribeOk v15: round trip", async () => { + const msg = new Subscribe.SubscribeOk(42n, 43n); + + const encoded = await encodeVersioned(msg, Version.DRAFT_15); + const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, Version.DRAFT_15); assert.strictEqual(decoded.requestId, 42n); assert.strictEqual(decoded.trackAlias, 43n); @@ -216,7 +263,7 @@ test("TrackStatus: round trip", async () => { }); // Validation tests -test("Subscribe: rejects invalid filter type", async () => { +test("Subscribe v14: rejects invalid filter type", async () => { const invalidBytes = new Uint8Array([ 0x01, // subscribe_id 0x02, // track_alias @@ -239,11 +286,11 @@ test("Subscribe: rejects invalid filter type", async () => { ]); await assert.rejects(async () => { - await decodeMessage(invalidBytes, Subscribe.Subscribe.decode); + await decodeVersioned(invalidBytes, Subscribe.Subscribe.decode, Version.DRAFT_14); }); }); -test("SubscribeOk: rejects non-zero expires", async () => { +test("SubscribeOk v14: rejects non-zero expires", async () => { const invalidBytes = new Uint8Array([ 0x01, // subscribe_id 0x05, // INVALID: expires = 5 @@ -253,7 +300,7 @@ test("SubscribeOk: rejects non-zero expires", async () => { ]); await assert.rejects(async () => { - await decodeMessage(invalidBytes, Subscribe.SubscribeOk.decode); + await decodeVersioned(invalidBytes, Subscribe.SubscribeOk.decode, Version.DRAFT_14); }); }); diff --git a/js/lite/src/ietf/index.ts b/js/lite/src/ietf/index.ts index 92e12818c..983090aeb 100644 --- a/js/lite/src/ietf/index.ts +++ b/js/lite/src/ietf/index.ts @@ -1,11 +1,13 @@ export * from "./connection.ts"; export * from "./control.ts"; +export * from "./fetch.ts"; export * from "./goaway.ts"; export * from "./object.ts"; export * from "./parameters.ts"; export * from "./publish.ts"; export * from "./publish_namespace.ts"; export * from "./publisher.ts"; +export * from "./request.ts"; export * from "./setup.ts"; export * from "./subscribe.ts"; export * from "./subscribe_namespace.ts"; diff --git a/js/lite/src/ietf/object.ts b/js/lite/src/ietf/object.ts index e37ec0c87..cb91bf03d 100644 --- a/js/lite/src/ietf/object.ts +++ b/js/lite/src/ietf/object.ts @@ -7,6 +7,9 @@ export interface GroupFlags { hasSubgroup: boolean; hasSubgroupObject: boolean; hasEnd: boolean; + // v15: whether priority is present in the header. + // When false (0x30 base), priority inherits from the control message. + hasPriority: boolean; } /** @@ -33,7 +36,8 @@ export class Group { throw new Error(`Subgroup ID must be 0 if hasSubgroup is false: ${this.subGroupId}`); } - let id = 0x10; + const base = this.flags.hasPriority ? 0x10 : 0x30; + let id = base; if (this.flags.hasExtensions) { id |= 0x01; } @@ -52,26 +56,38 @@ export class Group { if (this.flags.hasSubgroup) { await w.u53(this.subGroupId); } - await w.u8(0); // publisher priority + if (this.flags.hasPriority) { + await w.u8(this.publisherPriority); + } } static async decode(r: Reader): Promise { const id = await r.u53(); - if (id < 0x10 || id > 0x1f) { + + let hasPriority: boolean; + let baseId: number; + if (id >= 0x10 && id <= 0x1f) { + hasPriority = true; + baseId = id; + } else if (id >= 0x30 && id <= 0x3f) { + hasPriority = false; + baseId = id - (0x30 - 0x10); + } else { throw new Error(`Unsupported group type: ${id}`); } - const flags = { - hasExtensions: (id & 0x01) !== 0, - hasSubgroupObject: (id & 0x02) !== 0, - hasSubgroup: (id & 0x04) !== 0, - hasEnd: (id & 0x08) !== 0, + const flags: GroupFlags = { + hasExtensions: (baseId & 0x01) !== 0, + hasSubgroupObject: (baseId & 0x02) !== 0, + hasSubgroup: (baseId & 0x04) !== 0, + hasEnd: (baseId & 0x08) !== 0, + hasPriority, }; const trackAlias = await r.u62(); const groupId = await r.u53(); const subGroupId = flags.hasSubgroup ? await r.u53() : 0; - const publisherPriority = await r.u8(); // Don't care about publisher priority + const publisherPriority = hasPriority ? await r.u8() : 128; // Default priority when absent return new Group(trackAlias, groupId, subGroupId, publisherPriority, flags); } diff --git a/js/lite/src/ietf/parameters.ts b/js/lite/src/ietf/parameters.ts index f50710267..7b69d7267 100644 --- a/js/lite/src/ietf/parameters.ts +++ b/js/lite/src/ietf/parameters.ts @@ -107,3 +107,144 @@ export class Parameters { return params; } } + +// ---- Message Parameters (used in Subscribe, Publish, Fetch, etc.) ---- +// Uses raw bigint keys since parameter IDs have different meanings from setup parameters. + +// Varint parameter IDs (even) +const MSG_PARAM_DELIVERY_TIMEOUT = 0x02n; +const MSG_PARAM_MAX_CACHE_DURATION = 0x04n; +const MSG_PARAM_EXPIRES = 0x08n; +const MSG_PARAM_PUBLISHER_PRIORITY = 0x0en; +const MSG_PARAM_FORWARD = 0x10n; +const MSG_PARAM_SUBSCRIBER_PRIORITY = 0x20n; +const MSG_PARAM_GROUP_ORDER = 0x22n; + +// Bytes parameter IDs (odd) +const MSG_PARAM_SUBSCRIPTION_FILTER = 0x21n; + +export class MessageParameters { + vars: Map; + bytes: Map; + + constructor() { + this.vars = new Map(); + this.bytes = new Map(); + } + + // --- Varint accessors --- + + get subscriberPriority(): number | undefined { + const v = this.vars.get(MSG_PARAM_SUBSCRIBER_PRIORITY); + return v !== undefined ? Number(v) : undefined; + } + + set subscriberPriority(v: number) { + this.vars.set(MSG_PARAM_SUBSCRIBER_PRIORITY, BigInt(v)); + } + + get groupOrder(): number | undefined { + const v = this.vars.get(MSG_PARAM_GROUP_ORDER); + return v !== undefined ? Number(v) : undefined; + } + + set groupOrder(v: number) { + this.vars.set(MSG_PARAM_GROUP_ORDER, BigInt(v)); + } + + get forward(): boolean | undefined { + const v = this.vars.get(MSG_PARAM_FORWARD); + return v !== undefined ? v !== 0n : undefined; + } + + set forward(v: boolean) { + this.vars.set(MSG_PARAM_FORWARD, v ? 1n : 0n); + } + + get publisherPriority(): number | undefined { + const v = this.vars.get(MSG_PARAM_PUBLISHER_PRIORITY); + return v !== undefined ? Number(v) : undefined; + } + + set publisherPriority(v: number) { + this.vars.set(MSG_PARAM_PUBLISHER_PRIORITY, BigInt(v)); + } + + get expires(): bigint | undefined { + return this.vars.get(MSG_PARAM_EXPIRES); + } + + set expires(v: bigint) { + this.vars.set(MSG_PARAM_EXPIRES, v); + } + + get deliveryTimeout(): bigint | undefined { + return this.vars.get(MSG_PARAM_DELIVERY_TIMEOUT); + } + + set deliveryTimeout(v: bigint) { + this.vars.set(MSG_PARAM_DELIVERY_TIMEOUT, v); + } + + get maxCacheDuration(): bigint | undefined { + return this.vars.get(MSG_PARAM_MAX_CACHE_DURATION); + } + + set maxCacheDuration(v: bigint) { + this.vars.set(MSG_PARAM_MAX_CACHE_DURATION, v); + } + + // --- Bytes accessors --- + + get subscriptionFilter(): number | undefined { + const data = this.bytes.get(MSG_PARAM_SUBSCRIPTION_FILTER); + if (!data || data.length === 0) return undefined; + // Filter type is a varint — for our purposes, the first byte suffices + return data[0]; + } + + set subscriptionFilter(v: number) { + this.bytes.set(MSG_PARAM_SUBSCRIPTION_FILTER, new Uint8Array([v])); + } + + async encode(w: Writer) { + await w.u53(this.vars.size + this.bytes.size); + + for (const [id, value] of this.vars) { + await w.u62(id); + await w.u62(value); + } + + for (const [id, value] of this.bytes) { + await w.u62(id); + await w.u53(value.length); + await w.write(value); + } + } + + static async decode(r: Reader): Promise { + const count = await r.u53(); + const params = new MessageParameters(); + + for (let i = 0; i < count; i++) { + const id = await r.u62(); + + if (id % 2n === 0n) { + if (params.vars.has(id)) { + throw new Error(`duplicate message parameter id: ${id.toString()}`); + } + const varint = await r.u62(); + params.vars.set(id, varint); + } else { + if (params.bytes.has(id)) { + throw new Error(`duplicate message parameter id: ${id.toString()}`); + } + const size = await r.u53(); + const bytes = await r.read(size); + params.bytes.set(id, bytes); + } + } + + return params; + } +} diff --git a/js/lite/src/ietf/publish.ts b/js/lite/src/ietf/publish.ts index 0c1e3f8c8..999d260fd 100644 --- a/js/lite/src/ietf/publish.ts +++ b/js/lite/src/ietf/publish.ts @@ -2,10 +2,10 @@ import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; -import { Parameters } from "./parameters.ts"; +import { MessageParameters, Parameters } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; -// PUBLISH messages are new in draft-14 but not yet fully supported -// These are stubs matching the Rust implementation +// PUBLISH messages are new in draft-14 export class Publish { static id = 0x1d; @@ -39,52 +39,75 @@ export class Publish { this.forward = forward; } - async #encode(w: Writer): Promise { + async #encode(w: Writer, version: IetfVersion): Promise { await w.u62(this.requestId); await Namespace.encode(w, this.trackNamespace); await w.string(this.trackName); await w.u62(this.trackAlias); - await w.u8(this.groupOrder); - await w.bool(this.contentExists); - if (this.contentExists !== !!this.largest) { - throw new Error("contentExists and largest must both be true or false"); - } - if (this.largest) { - await w.u62(this.largest.groupId); - await w.u62(this.largest.objectId); + + if (version === Version.DRAFT_15) { + // v15: fields in parameters + const params = new MessageParameters(); + params.groupOrder = this.groupOrder; + params.forward = this.forward; + await params.encode(w); + } else if (version === Version.DRAFT_14) { + await w.u8(this.groupOrder); + await w.bool(this.contentExists); + if (this.contentExists !== !!this.largest) { + throw new Error("contentExists and largest must both be true or false"); + } + if (this.largest) { + await w.u62(this.largest.groupId); + await w.u62(this.largest.objectId); + } + await w.bool(this.forward); + await w.u53(0); // size of parameters + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); } - await w.bool(this.forward); - await w.u53(0); // size of parameters } - async encode(w: Writer): Promise { - return Message.encode(w, this.#encode.bind(this)); + async encode(w: Writer, version: IetfVersion): Promise { + return Message.encode(w, (mw) => this.#encode(mw, version)); } - static async decode(r: Reader): Promise { - return Message.decode(r, Publish.#decode); + static async decode(r: Reader, version: IetfVersion): Promise { + return Message.decode(r, (mr) => Publish.#decode(mr, version)); } - static async #decode(r: Reader): Promise { + static async #decode(r: Reader, version: IetfVersion): Promise { const requestId = await r.u62(); const trackNamespace = await Namespace.decode(r); const trackName = await r.string(); const trackAlias = await r.u62(); - const groupOrder = await r.u8(); - const contentExists = await r.bool(); - const largest = contentExists ? { groupId: await r.u62(), objectId: await r.u62() } : undefined; - const forward = await r.bool(); - await Parameters.decode(r); // ignore parameters - return new Publish( - requestId, - trackNamespace, - trackName, - trackAlias, - groupOrder, - contentExists, - largest, - forward, - ); + + if (version === Version.DRAFT_15) { + const params = await MessageParameters.decode(r); + const groupOrder = params.groupOrder ?? 0x02; + const forward = params.forward ?? true; + return new Publish(requestId, trackNamespace, trackName, trackAlias, groupOrder, false, undefined, forward); + } else if (version === Version.DRAFT_14) { + const groupOrder = await r.u8(); + const contentExists = await r.bool(); + const largest = contentExists ? { groupId: await r.u62(), objectId: await r.u62() } : undefined; + const forward = await r.bool(); + await Parameters.decode(r); // ignore parameters + return new Publish( + requestId, + trackNamespace, + trackName, + trackAlias, + groupOrder, + contentExists, + largest, + forward, + ); + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); + } } } diff --git a/js/lite/src/ietf/publisher.ts b/js/lite/src/ietf/publisher.ts index ab5031bea..ed2445346 100644 --- a/js/lite/src/ietf/publisher.ts +++ b/js/lite/src/ietf/publisher.ts @@ -14,6 +14,7 @@ import { type PublishNamespaceError, type PublishNamespaceOk, } from "./publish_namespace.ts"; +import type { RequestError, RequestOk } from "./request.ts"; import { type Subscribe, SubscribeError, SubscribeOk, type Unsubscribe } from "./subscribe.ts"; import type { SubscribeNamespace, UnsubscribeNamespace } from "./subscribe_namespace.ts"; import { TrackStatus, type TrackStatusRequest } from "./track.ts"; @@ -153,6 +154,7 @@ export class Publisher { hasSubgroupObject: false, // Automatically end the group on stream FIN hasEnd: true, + hasPriority: true, }); console.debug("sending group header", header); @@ -222,4 +224,14 @@ export class Publisher { async handleSubscribeNamespace(_msg: SubscribeNamespace) {} async handleUnsubscribeNamespace(_msg: UnsubscribeNamespace) {} + + // v15: REQUEST_OK replaces PublishNamespaceOk, SubscribeNamespaceOk + async handleRequestOk(_msg: RequestOk) { + // TODO: route by request_id to determine what kind of request it belongs to + } + + // v15: REQUEST_ERROR replaces SubscribeError, PublishError, etc. + async handleRequestError(_msg: RequestError) { + // TODO: route by request_id to determine what kind of request it belongs to + } } diff --git a/js/lite/src/ietf/request.ts b/js/lite/src/ietf/request.ts index 614dd5b5c..bdeed7446 100644 --- a/js/lite/src/ietf/request.ts +++ b/js/lite/src/ietf/request.ts @@ -1,5 +1,6 @@ import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; +import { MessageParameters } from "./parameters.ts"; export class MaxRequestId { static id = 0x15; @@ -52,3 +53,73 @@ export class RequestsBlocked { return Message.decode(r, RequestsBlocked.#decode); } } + +/// REQUEST_OK (0x07 in v15) - Generic success response for any request. +/// Replaces PublishNamespaceOk, SubscribeNamespaceOk in v15. +export class RequestOk { + static id = 0x07; + + requestId: bigint; + parameters: MessageParameters; + + constructor(requestId: bigint, parameters = new MessageParameters()) { + this.requestId = requestId; + this.parameters = parameters; + } + + async #encode(w: Writer): Promise { + await w.u62(this.requestId); + await this.parameters.encode(w); + } + + async encode(w: Writer): Promise { + return Message.encode(w, this.#encode.bind(this)); + } + + static async #decode(r: Reader): Promise { + const requestId = await r.u62(); + const parameters = await MessageParameters.decode(r); + return new RequestOk(requestId, parameters); + } + + static async decode(r: Reader): Promise { + return Message.decode(r, RequestOk.#decode); + } +} + +/// REQUEST_ERROR (0x05 in v15) - Generic error response for any request. +/// Replaces SubscribeError, PublishError, PublishNamespaceError, etc. in v15. +export class RequestError { + static id = 0x05; + + requestId: bigint; + errorCode: number; + reasonPhrase: string; + + constructor(requestId: bigint, errorCode: number, reasonPhrase: string) { + this.requestId = requestId; + this.errorCode = errorCode; + this.reasonPhrase = reasonPhrase; + } + + async #encode(w: Writer): Promise { + await w.u62(this.requestId); + await w.u62(BigInt(this.errorCode)); + await w.string(this.reasonPhrase); + } + + async encode(w: Writer): Promise { + return Message.encode(w, this.#encode.bind(this)); + } + + static async #decode(r: Reader): Promise { + const requestId = await r.u62(); + const errorCode = Number(await r.u62()); + const reasonPhrase = await r.string(); + return new RequestError(requestId, errorCode, reasonPhrase); + } + + static async decode(r: Reader): Promise { + return Message.decode(r, RequestError.#decode); + } +} diff --git a/js/lite/src/ietf/setup.ts b/js/lite/src/ietf/setup.ts index 2e4146022..118ed5d6f 100644 --- a/js/lite/src/ietf/setup.ts +++ b/js/lite/src/ietf/setup.ts @@ -1,6 +1,7 @@ import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; import { Parameters } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; const MAX_VERSIONS = 128; @@ -15,40 +16,56 @@ export class ClientSetup { this.parameters = parameters; } - async #encode(w: Writer): Promise { - await w.u53(this.versions.length); - for (const v of this.versions) { - await w.u53(v); + async #encode(w: Writer, version: IetfVersion): Promise { + if (version === Version.DRAFT_15) { + // Draft15: no versions list, just parameters + await this.parameters.encode(w); + } else if (version === Version.DRAFT_14) { + await w.u53(this.versions.length); + for (const v of this.versions) { + await w.u53(v); + } + await this.parameters.encode(w); + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); } - - await this.parameters.encode(w); } - async encode(w: Writer): Promise { - return Message.encode(w, this.#encode.bind(this)); + async encode(w: Writer, version: IetfVersion): Promise { + return Message.encode(w, (mw) => this.#encode(mw, version)); } - static async #decode(r: Reader): Promise { - // Number of supported versions - const numVersions = await r.u53(); - if (numVersions > MAX_VERSIONS) { - throw new Error(`too many versions: ${numVersions}`); + static async #decode(r: Reader, version: IetfVersion): Promise { + if (version === Version.DRAFT_15) { + // Draft15: no versions list, just parameters + const parameters = await Parameters.decode(r); + return new ClientSetup([Version.DRAFT_15], parameters); + } else if (version === Version.DRAFT_14) { + // Number of supported versions + const numVersions = await r.u53(); + if (numVersions > MAX_VERSIONS) { + throw new Error(`too many versions: ${numVersions}`); + } + + const supportedVersions: number[] = []; + + for (let i = 0; i < numVersions; i++) { + const v = await r.u53(); + supportedVersions.push(v); + } + + const parameters = await Parameters.decode(r); + + return new ClientSetup(supportedVersions, parameters); + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); } - - const supportedVersions: number[] = []; - - for (let i = 0; i < numVersions; i++) { - const version = await r.u53(); - supportedVersions.push(version); - } - - const parameters = await Parameters.decode(r); - - return new ClientSetup(supportedVersions, parameters); } - static async decode(r: Reader): Promise { - return Message.decode(r, ClientSetup.#decode); + static async decode(r: Reader, version: IetfVersion): Promise { + return Message.decode(r, (mr) => ClientSetup.#decode(mr, version)); } } @@ -63,24 +80,39 @@ export class ServerSetup { this.parameters = parameters; } - async #encode(w: Writer): Promise { - await w.u53(this.version); - await this.parameters.encode(w); + async #encode(w: Writer, encodeVersion: IetfVersion): Promise { + if (encodeVersion === Version.DRAFT_15) { + // Draft15: no version field, just parameters + await this.parameters.encode(w); + } else if (encodeVersion === Version.DRAFT_14) { + await w.u53(this.version); + await this.parameters.encode(w); + } else { + const _: never = encodeVersion; + throw new Error(`unsupported version: ${_}`); + } } - async encode(w: Writer): Promise { - return Message.encode(w, this.#encode.bind(this)); + async encode(w: Writer, version: IetfVersion): Promise { + return Message.encode(w, (mw) => this.#encode(mw, version)); } - static async #decode(r: Reader): Promise { - // Selected version - const selectedVersion = await r.u53(); - const parameters = await Parameters.decode(r); - - return new ServerSetup(selectedVersion, parameters); + static async #decode(r: Reader, version: IetfVersion): Promise { + if (version === Version.DRAFT_15) { + // Draft15: no version field, just parameters + const parameters = await Parameters.decode(r); + return new ServerSetup(Version.DRAFT_15, parameters); + } else if (version === Version.DRAFT_14) { + const selectedVersion = await r.u53(); + const parameters = await Parameters.decode(r); + return new ServerSetup(selectedVersion, parameters); + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); + } } - static async decode(r: Reader): Promise { - return Message.decode(r, ServerSetup.#decode); + static async decode(r: Reader, version: IetfVersion): Promise { + return Message.decode(r, (mr) => ServerSetup.#decode(mr, version)); } } diff --git a/js/lite/src/ietf/subscribe.ts b/js/lite/src/ietf/subscribe.ts index 3c9782330..0c3ff108e 100644 --- a/js/lite/src/ietf/subscribe.ts +++ b/js/lite/src/ietf/subscribe.ts @@ -2,7 +2,8 @@ import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; -import { Parameters } from "./parameters.ts"; +import { MessageParameters, Parameters } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; // we only support Group Order descending const GROUP_ORDER = 0x02; @@ -22,49 +23,74 @@ export class Subscribe { this.subscriberPriority = subscriberPriority; } - async #encode(w: Writer): Promise { + async #encode(w: Writer, version: IetfVersion): Promise { await w.u62(this.requestId); await Namespace.encode(w, this.trackNamespace); await w.string(this.trackName); - await w.u8(this.subscriberPriority); - await w.u8(GROUP_ORDER); - await w.bool(true); // forward = true - await w.u53(0x2); // filter type = LargestObject - await w.u53(0); // no parameters + + if (version === Version.DRAFT_15) { + // v15: fields moved into parameters + const params = new MessageParameters(); + params.subscriberPriority = this.subscriberPriority; + params.groupOrder = GROUP_ORDER; + params.forward = true; + params.subscriptionFilter = 0x2; // LargestObject + await params.encode(w); + } else if (version === Version.DRAFT_14) { + await w.u8(this.subscriberPriority); + await w.u8(GROUP_ORDER); + await w.bool(true); // forward = true + await w.u53(0x2); // filter type = LargestObject + await w.u53(0); // no parameters + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); + } } - async encode(w: Writer): Promise { - return Message.encode(w, this.#encode.bind(this)); + async encode(w: Writer, version: IetfVersion): Promise { + return Message.encode(w, (mw) => this.#encode(mw, version)); } - static async decode(r: Reader): Promise { - return Message.decode(r, Subscribe.#decode); + static async decode(r: Reader, version: IetfVersion): Promise { + return Message.decode(r, (mr) => Subscribe.#decode(mr, version)); } - static async #decode(r: Reader): Promise { + static async #decode(r: Reader, version: IetfVersion): Promise { const requestId = await r.u62(); const trackNamespace = await Namespace.decode(r); const trackName = await r.string(); - const subscriberPriority = await r.u8(); - const groupOrder = await r.u8(); - if (groupOrder > 2) { - throw new Error(`unknown group order: ${groupOrder}`); + if (version === Version.DRAFT_15) { + // v15: fields are in parameters + const params = await MessageParameters.decode(r); + const subscriberPriority = params.subscriberPriority ?? 128; + return new Subscribe(requestId, trackNamespace, trackName, subscriberPriority); + } else if (version === Version.DRAFT_14) { + const subscriberPriority = await r.u8(); + + const groupOrder = await r.u8(); + if (groupOrder > 2) { + throw new Error(`unknown group order: ${groupOrder}`); + } + + const forward = await r.bool(); + if (!forward) { + throw new Error(`unsupported forward value: ${forward}`); + } + + const filterType = await r.u53(); + if (filterType !== 0x1 && filterType !== 0x2) { + throw new Error(`unsupported filter type: ${filterType}`); + } + + await Parameters.decode(r); // ignore parameters + + return new Subscribe(requestId, trackNamespace, trackName, subscriberPriority); + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); } - - const forward = await r.bool(); - if (!forward) { - throw new Error(`unsupported forward value: ${forward}`); - } - - const filterType = await r.u53(); - if (filterType !== 0x1 && filterType !== 0x2) { - throw new Error(`unsupported filter type: ${filterType}`); - } - - await Parameters.decode(r); // ignore parameters - - return new Subscribe(requestId, trackNamespace, trackName, subscriberPriority); } } @@ -79,42 +105,62 @@ export class SubscribeOk { this.trackAlias = trackAlias; } - async #encode(w: Writer): Promise { + async #encode(w: Writer, version: IetfVersion): Promise { await w.u62(this.requestId); await w.u62(this.trackAlias); - await w.u62(0n); // expires = 0 - await w.u8(GROUP_ORDER); - await w.bool(false); // content exists = false - await w.u53(0); // no parameters + + if (version === Version.DRAFT_15) { + // v15: just parameters after track_alias + const params = new MessageParameters(); + params.groupOrder = GROUP_ORDER; + await params.encode(w); + } else if (version === Version.DRAFT_14) { + await w.u62(0n); // expires = 0 + await w.u8(GROUP_ORDER); + await w.bool(false); // content exists = false + await w.u53(0); // no parameters + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); + } } - async encode(w: Writer): Promise { - return Message.encode(w, this.#encode.bind(this)); + async encode(w: Writer, version: IetfVersion): Promise { + return Message.encode(w, (mw) => this.#encode(mw, version)); } - static async decode(r: Reader): Promise { - return Message.decode(r, SubscribeOk.#decode); + static async decode(r: Reader, version: IetfVersion): Promise { + return Message.decode(r, (mr) => SubscribeOk.#decode(mr, version)); } - static async #decode(r: Reader): Promise { + static async #decode(r: Reader, version: IetfVersion): Promise { const requestId = await r.u62(); const trackAlias = await r.u62(); - const expires = await r.u62(); - if (expires !== BigInt(0)) { - throw new Error(`unsupported expires: ${expires}`); - } - await r.u8(); // Don't care about group order - - const contentExists = await r.bool(); - if (contentExists) { - // Ignore largest group/object - await r.u62(); - await r.u62(); + if (version === Version.DRAFT_15) { + // v15: just parameters + await MessageParameters.decode(r); + } else if (version === Version.DRAFT_14) { + const expires = await r.u62(); + if (expires !== BigInt(0)) { + throw new Error(`unsupported expires: ${expires}`); + } + + await r.u8(); // Don't care about group order + + const contentExists = await r.bool(); + if (contentExists) { + // Ignore largest group/object + await r.u62(); + await r.u62(); + } + + await Parameters.decode(r); // ignore parameters + } else { + const _: never = version; + throw new Error(`unsupported version: ${_}`); } - await Parameters.decode(r); // ignore parameters - return new SubscribeOk(requestId, trackAlias); } } diff --git a/js/lite/src/ietf/subscriber.ts b/js/lite/src/ietf/subscriber.ts index 8d93f144a..19421e294 100644 --- a/js/lite/src/ietf/subscriber.ts +++ b/js/lite/src/ietf/subscriber.ts @@ -9,6 +9,7 @@ import type * as Control from "./control.ts"; import { Frame, type Group as GroupMessage } from "./object.ts"; import { type Publish, type PublishDone, PublishError } from "./publish.ts"; import type { PublishNamespace, PublishNamespaceDone } from "./publish_namespace.ts"; +import type { RequestError, RequestOk } from "./request.ts"; import { Subscribe, type SubscribeError, type SubscribeOk, Unsubscribe } from "./subscribe.ts"; import { SubscribeNamespace, @@ -317,4 +318,22 @@ export class Subscriber { async handleTrackStatus(_msg: TrackStatus) { throw new Error("TRACK_STATUS messages are not supported"); } + + // v15: REQUEST_OK replaces SubscribeNamespaceOk, PublishNamespaceOk + async handleRequestOk(msg: RequestOk) { + // In v15, RequestOk is used for subscribe namespace acknowledgements + // Route by request_id — treat similarly to SubscribeNamespaceOk for now + console.debug("received REQUEST_OK", msg.requestId); + } + + // v15: REQUEST_ERROR replaces SubscribeNamespaceError, etc. + async handleRequestError(msg: RequestError) { + // In v15, RequestError replaces SubscribeError for subscribe requests + const callback = this.#subscribeCallbacks.get(msg.requestId); + if (callback) { + callback.reject(new Error(`REQUEST_ERROR: code=${msg.errorCode} reason=${msg.reasonPhrase}`)); + } else { + console.warn("handleRequestError unknown requestId", msg.requestId); + } + } } diff --git a/js/lite/src/ietf/version.ts b/js/lite/src/ietf/version.ts index ae63dfc42..d40e6c196 100644 --- a/js/lite/src/ietf/version.ts +++ b/js/lite/src/ietf/version.ts @@ -13,6 +13,18 @@ export const Version = { * https://www.ietf.org/archive/id/draft-ietf-moq-transport-14.txt */ DRAFT_14: 0xff00000e, + + /** + * draft-ietf-moq-transport-15 + * https://www.ietf.org/archive/id/draft-ietf-moq-transport-15.txt + */ + DRAFT_15: 0xff00000f, } as const; export type Version = (typeof Version)[keyof typeof Version]; + +/** + * IETF protocol versions used by the ietf/ module. + * Use this narrower type for version-branched encode/decode to get exhaustive matching. + */ +export type IetfVersion = typeof Version.DRAFT_14 | typeof Version.DRAFT_15; diff --git a/rs/moq-lite/src/ietf/group.rs b/rs/moq-lite/src/ietf/group.rs index 3be887714..9c0017e65 100644 --- a/rs/moq-lite/src/ietf/group.rs +++ b/rs/moq-lite/src/ietf/group.rs @@ -58,7 +58,11 @@ impl GroupFlags { "has_subgroup and has_subgroup_object cannot be true at the same time" ); - let base = if self.has_priority { Self::START } else { Self::START_NO_PRIORITY }; + let base = if self.has_priority { + Self::START + } else { + Self::START_NO_PRIORITY + }; let mut id: u64 = base; if self.has_extensions { id |= 0x01; diff --git a/rs/moq-lite/src/ietf/publish.rs b/rs/moq-lite/src/ietf/publish.rs index d13999e98..aef462c06 100644 --- a/rs/moq-lite/src/ietf/publish.rs +++ b/rs/moq-lite/src/ietf/publish.rs @@ -110,8 +110,7 @@ use crate::{ Path, coding::{Decode, DecodeError, Encode}, ietf::{ - FilterType, GroupOrder, Location, Message, MessageParameters, Parameters, RequestId, - Version, + FilterType, GroupOrder, Location, Message, MessageParameters, Parameters, RequestId, Version, namespace::{decode_namespace, encode_namespace}, }, }; diff --git a/rs/moq-lite/src/ietf/publisher.rs b/rs/moq-lite/src/ietf/publisher.rs index 9f001aff5..985779afc 100644 --- a/rs/moq-lite/src/ietf/publisher.rs +++ b/rs/moq-lite/src/ietf/publisher.rs @@ -7,7 +7,9 @@ use web_transport_trait::SendStream; use crate::{ Error, Origin, OriginConsumer, Track, TrackConsumer, coding::Writer, - ietf::{self, Control, FetchHeader, FetchType, FilterType, GroupOrder, Location, MessageParameters, RequestId, Version}, + ietf::{ + self, Control, FetchHeader, FetchType, FilterType, GroupOrder, Location, MessageParameters, RequestId, Version, + }, model::GroupConsumer, }; diff --git a/rs/moq-lite/src/ietf/request.rs b/rs/moq-lite/src/ietf/request.rs index d13899bb3..6500a5469 100644 --- a/rs/moq-lite/src/ietf/request.rs +++ b/rs/moq-lite/src/ietf/request.rs @@ -91,10 +91,7 @@ impl Message for RequestOk { fn decode_msg(r: &mut R, version: Version) -> Result { let request_id = RequestId::decode(r, version)?; let parameters = MessageParameters::decode(r, version)?; - Ok(Self { - request_id, - parameters, - }) + Ok(Self { request_id, parameters }) } } diff --git a/rs/moq-lite/src/ietf/session.rs b/rs/moq-lite/src/ietf/session.rs index 715f53d54..08b6f9012 100644 --- a/rs/moq-lite/src/ietf/session.rs +++ b/rs/moq-lite/src/ietf/session.rs @@ -103,42 +103,38 @@ async fn run_control_read( subscriber.recv_subscribe_ok(msg)?; } // 0x05: SubscribeError in v14, REQUEST_ERROR in v15 - ietf::SubscribeError::ID => { - match version { - Version::Draft14 => { - let msg = ietf::SubscribeError::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - subscriber.recv_subscribe_error(msg)?; - } - Version::Draft15 => { - let msg = ietf::RequestError::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - subscriber.recv_request_error(&msg)?; - publisher.recv_request_error(&msg)?; - } + ietf::SubscribeError::ID => match version { + Version::Draft14 => { + let msg = ietf::SubscribeError::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_subscribe_error(msg)?; } - } + Version::Draft15 => { + let msg = ietf::RequestError::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_request_error(&msg)?; + publisher.recv_request_error(&msg)?; + } + }, ietf::PublishNamespace::ID => { let msg = ietf::PublishNamespace::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_publish_namespace(msg)?; } // 0x07: PublishNamespaceOk in v14, REQUEST_OK in v15 - ietf::PublishNamespaceOk::ID => { - match version { - Version::Draft14 => { - let msg = ietf::PublishNamespaceOk::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - publisher.recv_publish_namespace_ok(msg)?; - } - Version::Draft15 => { - let msg = ietf::RequestOk::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - subscriber.recv_request_ok(&msg)?; - publisher.recv_request_ok(&msg)?; - } + ietf::PublishNamespaceOk::ID => match version { + Version::Draft14 => { + let msg = ietf::PublishNamespaceOk::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + publisher.recv_publish_namespace_ok(msg)?; } - } + Version::Draft15 => { + let msg = ietf::RequestOk::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_request_ok(&msg)?; + publisher.recv_request_ok(&msg)?; + } + }, ietf::PublishNamespaceError::ID => { let msg = ietf::PublishNamespaceError::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); From 6380522b3f076a4f156670ce0fdac4003fe83ac6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Feb 2026 17:15:00 -0800 Subject: [PATCH 3/7] More work on draft-15. --- AGENTS.md | 1 + CLAUDE.md | 4 +- js/lite/src/ietf/control.ts | 2 +- js/lite/src/ietf/ietf.test.ts | 95 +++++++++++++++++++++++++++- rs/moq-lite/src/ietf/fetch.rs | 93 ++++++++++++++++++++++++++++ rs/moq-lite/src/ietf/parameters.rs | 19 +++--- rs/moq-lite/src/ietf/publish.rs | 99 ++++++++++++++++++++++++++++++ rs/moq-lite/src/ietf/request.rs | 46 ++++++++++++++ rs/moq-lite/src/ietf/session.rs | 78 +++++++++++++---------- rs/moq-lite/src/ietf/setup.rs | 73 ++++++++++++++++++++++ rs/moq-lite/src/ietf/subscribe.rs | 42 +++++++++++++ rs/moq-lite/src/ietf/track.rs | 49 +++++++++++++++ 12 files changed, 554 insertions(+), 47 deletions(-) create mode 120000 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e5fa919ed..1d9a36c18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance for AI coding agents when working with code in this repository. ## Project Overview @@ -14,7 +14,7 @@ just fix # Auto-fix linting issues just build # Build all packages ``` -Except when being run in the Claude Code on the web: Use cargo/bun directly. +If `just` is unavailable, use `cargo` or `bun` directly. ## Architecture diff --git a/js/lite/src/ietf/control.ts b/js/lite/src/ietf/control.ts index 9e3dbab51..90893f41f 100644 --- a/js/lite/src/ietf/control.ts +++ b/js/lite/src/ietf/control.ts @@ -55,7 +55,7 @@ const MessagesV14 = { } as const; // v15 message map — 0x05 → RequestError, 0x07 → RequestOk (different wire format) -// Messages removed in v15 (0x08, 0x0E, 0x12, 0x13, 0x19, 0x1F) are still kept for decoding compatibility +// Messages removed in v15 (0x08, 0x0E, 0x12, 0x13, 0x19, 0x1E, 0x1F) are excluded and will be rejected const MessagesV15 = { [Setup.ClientSetup.id]: Setup.ClientSetup, [Setup.ServerSetup.id]: Setup.ServerSetup, diff --git a/js/lite/src/ietf/ietf.test.ts b/js/lite/src/ietf/ietf.test.ts index a41c395cc..f49886394 100644 --- a/js/lite/src/ietf/ietf.test.ts +++ b/js/lite/src/ietf/ietf.test.ts @@ -3,8 +3,10 @@ import test from "node:test"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import * as GoAway from "./goaway.ts"; -import { PublishDone } from "./publish.ts"; +import { Publish, PublishDone } from "./publish.ts"; import * as Announce from "./publish_namespace.ts"; +import { RequestError, RequestOk } from "./request.ts"; +import * as Setup from "./setup.ts"; import * as Subscribe from "./subscribe.ts"; import * as Track from "./track.ts"; import { type IetfVersion, Version } from "./version.ts"; @@ -325,3 +327,94 @@ test("PublishNamespace: unicode namespace", async () => { assert.strictEqual(decoded.requestId, 1n); assert.strictEqual(decoded.trackNamespace, "会议/房间"); }); + +// Publish v15 tests +test("Publish v15: round trip", async () => { + const msg = new Publish(1n, Path.from("test/ns"), "video", 42n, 0x02, false, undefined, true); + + const encoded = await encodeVersioned(msg, Version.DRAFT_15); + const decoded = await decodeVersioned(encoded, Publish.decode, Version.DRAFT_15); + + assert.strictEqual(decoded.requestId, 1n); + assert.strictEqual(decoded.trackNamespace, "test/ns"); + assert.strictEqual(decoded.trackName, "video"); + assert.strictEqual(decoded.trackAlias, 42n); + assert.strictEqual(decoded.forward, true); +}); + +test("Publish v14: round trip", async () => { + const msg = new Publish(1n, Path.from("test/ns"), "video", 42n, 0x02, true, { groupId: 10n, objectId: 5n }, true); + + const encoded = await encodeVersioned(msg, Version.DRAFT_14); + const decoded = await decodeVersioned(encoded, Publish.decode, Version.DRAFT_14); + + assert.strictEqual(decoded.requestId, 1n); + assert.strictEqual(decoded.trackNamespace, "test/ns"); + assert.strictEqual(decoded.trackName, "video"); + assert.strictEqual(decoded.trackAlias, 42n); + assert.strictEqual(decoded.forward, true); + assert.strictEqual(decoded.contentExists, true); + assert.strictEqual(decoded.largest?.groupId, 10n); + assert.strictEqual(decoded.largest?.objectId, 5n); +}); + +// ClientSetup v15 tests +test("ClientSetup v15: round trip", async () => { + const msg = new Setup.ClientSetup([Version.DRAFT_15]); + + const encoded = await encodeVersioned(msg, Version.DRAFT_15); + const decoded = await decodeVersioned(encoded, Setup.ClientSetup.decode, Version.DRAFT_15); + + assert.strictEqual(decoded.versions.length, 1); + assert.strictEqual(decoded.versions[0], Version.DRAFT_15); +}); + +test("ClientSetup v14: round trip", async () => { + const msg = new Setup.ClientSetup([Version.DRAFT_14]); + + const encoded = await encodeVersioned(msg, Version.DRAFT_14); + const decoded = await decodeVersioned(encoded, Setup.ClientSetup.decode, Version.DRAFT_14); + + assert.strictEqual(decoded.versions.length, 1); + assert.strictEqual(decoded.versions[0], Version.DRAFT_14); +}); + +// ServerSetup v15 tests +test("ServerSetup v15: round trip", async () => { + const msg = new Setup.ServerSetup(Version.DRAFT_15); + + const encoded = await encodeVersioned(msg, Version.DRAFT_15); + const decoded = await decodeVersioned(encoded, Setup.ServerSetup.decode, Version.DRAFT_15); + + assert.strictEqual(decoded.version, Version.DRAFT_15); +}); + +test("ServerSetup v14: round trip", async () => { + const msg = new Setup.ServerSetup(Version.DRAFT_14); + + const encoded = await encodeVersioned(msg, Version.DRAFT_14); + const decoded = await decodeVersioned(encoded, Setup.ServerSetup.decode, Version.DRAFT_14); + + assert.strictEqual(decoded.version, Version.DRAFT_14); +}); + +// RequestOk / RequestError v15 tests +test("RequestOk: round trip", async () => { + const msg = new RequestOk(42n); + + const encoded = await encodeMessage(msg); + const decoded = await decodeMessage(encoded, RequestOk.decode); + + assert.strictEqual(decoded.requestId, 42n); +}); + +test("RequestError: round trip", async () => { + const msg = new RequestError(99n, 500, "Internal error"); + + const encoded = await encodeMessage(msg); + const decoded = await decodeMessage(encoded, RequestError.decode); + + assert.strictEqual(decoded.requestId, 99n); + assert.strictEqual(decoded.errorCode, 500); + assert.strictEqual(decoded.reasonPhrase, "Internal error"); +}); diff --git a/rs/moq-lite/src/ietf/fetch.rs b/rs/moq-lite/src/ietf/fetch.rs index 8b4d32532..2c41ed768 100644 --- a/rs/moq-lite/src/ietf/fetch.rs +++ b/rs/moq-lite/src/ietf/fetch.rs @@ -320,3 +320,96 @@ pub struct FetchObject { Object Payload (..), */ } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + + fn encode_message(msg: &M, version: Version) -> Vec { + let mut buf = BytesMut::new(); + msg.encode_msg(&mut buf, version); + buf.to_vec() + } + + fn decode_message(bytes: &[u8], version: Version) -> Result { + let mut buf = bytes::Bytes::from(bytes.to_vec()); + M::decode_msg(&mut buf, version) + } + + #[test] + fn test_fetch_v14_round_trip() { + let msg = Fetch { + request_id: RequestId(1), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + fetch_type: FetchType::Standalone { + namespace: Path::new("test"), + track: "video".into(), + start: Location { group: 0, object: 0 }, + end: Location { group: 10, object: 5 }, + }, + }; + + let encoded = encode_message(&msg, Version::Draft14); + let decoded: Fetch = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(1)); + assert_eq!(decoded.subscriber_priority, 128); + } + + #[test] + fn test_fetch_v15_round_trip() { + let msg = Fetch { + request_id: RequestId(1), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + fetch_type: FetchType::Standalone { + namespace: Path::new("test"), + track: "video".into(), + start: Location { group: 0, object: 0 }, + end: Location { group: 10, object: 5 }, + }, + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: Fetch = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(1)); + assert_eq!(decoded.subscriber_priority, 128); + } + + #[test] + fn test_fetch_ok_v14_round_trip() { + let msg = FetchOk { + request_id: RequestId(2), + group_order: GroupOrder::Descending, + end_of_track: false, + end_location: Location { group: 5, object: 3 }, + }; + + let encoded = encode_message(&msg, Version::Draft14); + let decoded: FetchOk = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(2)); + assert!(!decoded.end_of_track); + assert_eq!(decoded.end_location, Location { group: 5, object: 3 }); + } + + #[test] + fn test_fetch_ok_v15_round_trip() { + let msg = FetchOk { + request_id: RequestId(2), + group_order: GroupOrder::Descending, + end_of_track: false, + end_location: Location { group: 5, object: 3 }, + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: FetchOk = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(2)); + assert!(!decoded.end_of_track); + assert_eq!(decoded.end_location, Location { group: 5, object: 3 }); + } +} diff --git a/rs/moq-lite/src/ietf/parameters.rs b/rs/moq-lite/src/ietf/parameters.rs index 17077ecac..b8efe4b1b 100644 --- a/rs/moq-lite/src/ietf/parameters.rs +++ b/rs/moq-lite/src/ietf/parameters.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, hash_map}; +use std::collections::{BTreeMap, HashMap, btree_map, hash_map}; use num_enum::{FromPrimitive, IntoPrimitive}; @@ -104,17 +104,18 @@ impl Parameters { // ---- Message Parameters (used in Subscribe, Publish, Fetch, etc.) ---- // Uses raw u64 keys since parameter IDs have different meanings from setup parameters. +// BTreeMap ensures deterministic wire encoding order. #[derive(Default, Debug, Clone)] pub struct MessageParameters { - vars: HashMap, - bytes: HashMap>, + vars: BTreeMap, + bytes: BTreeMap>, } impl Decode for MessageParameters { fn decode(mut r: &mut R, version: V) -> Result { - let mut vars = HashMap::new(); - let mut bytes = HashMap::new(); + let mut vars = BTreeMap::new(); + let mut bytes = BTreeMap::new(); let count = u64::decode(r, version.clone())?; @@ -127,13 +128,13 @@ impl Decode for MessageParameters { if kind % 2 == 0 { match vars.entry(kind) { - hash_map::Entry::Occupied(_) => return Err(DecodeError::Duplicate), - hash_map::Entry::Vacant(entry) => entry.insert(u64::decode(&mut r, version.clone())?), + btree_map::Entry::Occupied(_) => return Err(DecodeError::Duplicate), + btree_map::Entry::Vacant(entry) => entry.insert(u64::decode(&mut r, version.clone())?), }; } else { match bytes.entry(kind) { - hash_map::Entry::Occupied(_) => return Err(DecodeError::Duplicate), - hash_map::Entry::Vacant(entry) => entry.insert(Vec::::decode(&mut r, version.clone())?), + btree_map::Entry::Occupied(_) => return Err(DecodeError::Duplicate), + btree_map::Entry::Vacant(entry) => entry.insert(Vec::::decode(&mut r, version.clone())?), }; } } diff --git a/rs/moq-lite/src/ietf/publish.rs b/rs/moq-lite/src/ietf/publish.rs index aef462c06..9a0032417 100644 --- a/rs/moq-lite/src/ietf/publish.rs +++ b/rs/moq-lite/src/ietf/publish.rs @@ -368,3 +368,102 @@ impl Message for PublishError<'_> { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + + fn encode_message(msg: &M, version: Version) -> Vec { + let mut buf = BytesMut::new(); + msg.encode_msg(&mut buf, version); + buf.to_vec() + } + + fn decode_message(bytes: &[u8], version: Version) -> Result { + let mut buf = bytes::Bytes::from(bytes.to_vec()); + M::decode_msg(&mut buf, version) + } + + #[test] + fn test_publish_v14_round_trip() { + let msg = Publish { + request_id: RequestId(1), + track_namespace: Path::new("test/ns"), + track_name: "video".into(), + track_alias: 42, + group_order: GroupOrder::Descending, + largest_location: Some(Location { group: 10, object: 5 }), + forward: true, + }; + + let encoded = encode_message(&msg, Version::Draft14); + let decoded: Publish = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(1)); + assert_eq!(decoded.track_namespace.as_str(), "test/ns"); + assert_eq!(decoded.track_name, "video"); + assert_eq!(decoded.track_alias, 42); + assert_eq!(decoded.largest_location, Some(Location { group: 10, object: 5 })); + assert!(decoded.forward); + } + + #[test] + fn test_publish_v15_round_trip() { + let msg = Publish { + request_id: RequestId(1), + track_namespace: Path::new("test/ns"), + track_name: "video".into(), + track_alias: 42, + group_order: GroupOrder::Descending, + largest_location: Some(Location { group: 10, object: 5 }), + forward: true, + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: Publish = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(1)); + assert_eq!(decoded.track_namespace.as_str(), "test/ns"); + assert_eq!(decoded.track_name, "video"); + assert_eq!(decoded.track_alias, 42); + assert_eq!(decoded.largest_location, Some(Location { group: 10, object: 5 })); + assert!(decoded.forward); + } + + #[test] + fn test_publish_ok_v14_round_trip() { + let msg = PublishOk { + request_id: RequestId(7), + forward: true, + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter_type: FilterType::LargestObject, + }; + + let encoded = encode_message(&msg, Version::Draft14); + let decoded: PublishOk = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(7)); + assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, 128); + } + + #[test] + fn test_publish_ok_v15_round_trip() { + let msg = PublishOk { + request_id: RequestId(7), + forward: true, + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter_type: FilterType::LargestObject, + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: PublishOk = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(7)); + assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, 128); + } +} diff --git a/rs/moq-lite/src/ietf/request.rs b/rs/moq-lite/src/ietf/request.rs index 6500a5469..1e6951f48 100644 --- a/rs/moq-lite/src/ietf/request.rs +++ b/rs/moq-lite/src/ietf/request.rs @@ -125,3 +125,49 @@ impl Message for RequestError<'_> { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + + fn encode_message(msg: &M, version: Version) -> Vec { + let mut buf = BytesMut::new(); + msg.encode_msg(&mut buf, version); + buf.to_vec() + } + + fn decode_message(bytes: &[u8], version: Version) -> Result { + let mut buf = bytes::Bytes::from(bytes.to_vec()); + M::decode_msg(&mut buf, version) + } + + #[test] + fn test_request_ok_round_trip() { + let msg = RequestOk { + request_id: RequestId(42), + parameters: MessageParameters::default(), + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: RequestOk = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(42)); + } + + #[test] + fn test_request_error_round_trip() { + let msg = RequestError { + request_id: RequestId(99), + error_code: 500, + reason_phrase: "Internal error".into(), + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: RequestError = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(99)); + assert_eq!(decoded.error_code, 500); + assert_eq!(decoded.reason_phrase, "Internal error"); + } +} diff --git a/rs/moq-lite/src/ietf/session.rs b/rs/moq-lite/src/ietf/session.rs index 08b6f9012..42fd94d16 100644 --- a/rs/moq-lite/src/ietf/session.rs +++ b/rs/moq-lite/src/ietf/session.rs @@ -135,11 +135,15 @@ async fn run_control_read( publisher.recv_request_ok(&msg)?; } }, - ietf::PublishNamespaceError::ID => { - let msg = ietf::PublishNamespaceError::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - publisher.recv_publish_namespace_error(msg)?; - } + // 0x08: PublishNamespaceError in v14, removed in v15 (replaced by RequestError 0x05) + ietf::PublishNamespaceError::ID => match version { + Version::Draft14 => { + let msg = ietf::PublishNamespaceError::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + publisher.recv_publish_namespace_error(msg)?; + } + Version::Draft15 => return Err(Error::UnexpectedMessage), + }, ietf::PublishNamespaceDone::ID => { let msg = ietf::PublishNamespaceDone::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); @@ -175,16 +179,24 @@ async fn run_control_read( tracing::debug!(message = ?msg, "received control message"); publisher.recv_subscribe_namespace(msg)?; } - ietf::SubscribeNamespaceOk::ID => { - let msg = ietf::SubscribeNamespaceOk::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - subscriber.recv_subscribe_namespace_ok(msg)?; - } - ietf::SubscribeNamespaceError::ID => { - let msg = ietf::SubscribeNamespaceError::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - subscriber.recv_subscribe_namespace_error(msg)?; - } + // 0x12: SubscribeNamespaceOk in v14, removed in v15 (replaced by RequestOk 0x07) + ietf::SubscribeNamespaceOk::ID => match version { + Version::Draft14 => { + let msg = ietf::SubscribeNamespaceOk::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_subscribe_namespace_ok(msg)?; + } + Version::Draft15 => return Err(Error::UnexpectedMessage), + }, + // 0x13: SubscribeNamespaceError in v14, removed in v15 (replaced by RequestError 0x05) + ietf::SubscribeNamespaceError::ID => match version { + Version::Draft14 => { + let msg = ietf::SubscribeNamespaceError::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_subscribe_namespace_error(msg)?; + } + Version::Draft15 => return Err(Error::UnexpectedMessage), + }, ietf::UnsubscribeNamespace::ID => { let msg = ietf::UnsubscribeNamespace::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); @@ -215,30 +227,28 @@ async fn run_control_read( tracing::debug!(message = ?msg, "received control message"); subscriber.recv_fetch_ok(msg)?; } - ietf::FetchError::ID => { - let msg = ietf::FetchError::decode_msg(&mut data, version)?; - tracing::debug!(message = ?msg, "received control message"); - subscriber.recv_fetch_error(msg)?; - } + // 0x19: FetchError in v14, removed in v15 (replaced by RequestError 0x05) + ietf::FetchError::ID => match version { + Version::Draft14 => { + let msg = ietf::FetchError::decode_msg(&mut data, version)?; + tracing::debug!(message = ?msg, "received control message"); + subscriber.recv_fetch_error(msg)?; + } + Version::Draft15 => return Err(Error::UnexpectedMessage), + }, ietf::Publish::ID => { let msg = ietf::Publish::decode_msg(&mut data, version)?; tracing::debug!(message = ?msg, "received control message"); subscriber.recv_publish(msg)?; } - ietf::PublishOk::ID => { - tracing::debug!( - message_id = ietf::PublishOk::ID, - "received control message (unsupported)" - ); - return Err(Error::Unsupported); - } - ietf::PublishError::ID => { - tracing::debug!( - message_id = ietf::PublishError::ID, - "received control message (unsupported)" - ); - return Err(Error::Unsupported); - } + // 0x1E: PublishOk — v14: unsupported, v15: removed (replaced by RequestOk 0x07) + ietf::PublishOk::ID => match version { + Version::Draft14 | Version::Draft15 => return Err(Error::UnexpectedMessage), + }, + // 0x1F: PublishError — v14: unsupported, v15: removed (replaced by RequestError 0x05) + ietf::PublishError::ID => match version { + Version::Draft14 | Version::Draft15 => return Err(Error::UnexpectedMessage), + }, _ => return Err(Error::UnexpectedMessage), } diff --git a/rs/moq-lite/src/ietf/setup.rs b/rs/moq-lite/src/ietf/setup.rs index b51e38829..4f0fa69fd 100644 --- a/rs/moq-lite/src/ietf/setup.rs +++ b/rs/moq-lite/src/ietf/setup.rs @@ -94,3 +94,76 @@ impl Message for ServerSetup { } } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + + fn encode_message(msg: &M, version: IetfVersion) -> Vec { + let mut buf = BytesMut::new(); + msg.encode_msg(&mut buf, version); + buf.to_vec() + } + + fn decode_message(bytes: &[u8], version: IetfVersion) -> Result { + let mut buf = bytes::Bytes::from(bytes.to_vec()); + M::decode_msg(&mut buf, version) + } + + #[test] + fn test_client_setup_v14_round_trip() { + let msg = ClientSetup { + versions: vec![Version(IetfVersion::Draft14 as u64)].into(), + parameters: Parameters::default(), + }; + + let encoded = encode_message(&msg, IetfVersion::Draft14); + let decoded: ClientSetup = decode_message(&encoded, IetfVersion::Draft14).unwrap(); + + assert_eq!(decoded.versions.len(), 1); + assert_eq!(decoded.versions[0], Version(IetfVersion::Draft14 as u64)); + } + + #[test] + fn test_client_setup_v15_round_trip() { + let msg = ClientSetup { + versions: vec![Version(IetfVersion::Draft15 as u64)].into(), + parameters: Parameters::default(), + }; + + let encoded = encode_message(&msg, IetfVersion::Draft15); + let decoded: ClientSetup = decode_message(&encoded, IetfVersion::Draft15).unwrap(); + + // v15 doesn't encode versions, so decoded should have [Draft15] + assert_eq!(decoded.versions.len(), 1); + assert_eq!(decoded.versions[0], Version(IetfVersion::Draft15 as u64)); + } + + #[test] + fn test_server_setup_v14_round_trip() { + let msg = ServerSetup { + version: Version(IetfVersion::Draft14 as u64), + parameters: Parameters::default(), + }; + + let encoded = encode_message(&msg, IetfVersion::Draft14); + let decoded: ServerSetup = decode_message(&encoded, IetfVersion::Draft14).unwrap(); + + assert_eq!(decoded.version, Version(IetfVersion::Draft14 as u64)); + } + + #[test] + fn test_server_setup_v15_round_trip() { + let msg = ServerSetup { + version: Version(IetfVersion::Draft15 as u64), + parameters: Parameters::default(), + }; + + let encoded = encode_message(&msg, IetfVersion::Draft15); + let decoded: ServerSetup = decode_message(&encoded, IetfVersion::Draft15).unwrap(); + + // v15 doesn't encode version, so decoded should be Draft15 + assert_eq!(decoded.version, Version(IetfVersion::Draft15 as u64)); + } +} diff --git a/rs/moq-lite/src/ietf/subscribe.rs b/rs/moq-lite/src/ietf/subscribe.rs index f88799bd5..d39cf3d41 100644 --- a/rs/moq-lite/src/ietf/subscribe.rs +++ b/rs/moq-lite/src/ietf/subscribe.rs @@ -475,6 +475,48 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_subscribe_update_v15_round_trip() { + let msg = SubscribeUpdate { + request_id: RequestId(10), + subscription_request_id: RequestId(5), + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: 200, + forward: true, + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: SubscribeUpdate = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(10)); + assert_eq!(decoded.subscription_request_id, RequestId(5)); + assert_eq!(decoded.subscriber_priority, 200); + assert!(decoded.forward); + } + + #[test] + fn test_subscribe_update_v14_round_trip() { + let msg = SubscribeUpdate { + request_id: RequestId(10), + subscription_request_id: RequestId(5), + start_location: Location { group: 1, object: 2 }, + end_group: 100, + subscriber_priority: 200, + forward: true, + }; + + let encoded = encode_message(&msg, Version::Draft14); + let decoded: SubscribeUpdate = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(10)); + assert_eq!(decoded.subscription_request_id, RequestId(5)); + assert_eq!(decoded.start_location, Location { group: 1, object: 2 }); + assert_eq!(decoded.end_group, 100); + assert_eq!(decoded.subscriber_priority, 200); + assert!(decoded.forward); + } + #[test] fn test_subscribe_ok_rejects_non_zero_expires() { #[rustfmt::skip] diff --git a/rs/moq-lite/src/ietf/track.rs b/rs/moq-lite/src/ietf/track.rs index 50809faa3..f2b749a2d 100644 --- a/rs/moq-lite/src/ietf/track.rs +++ b/rs/moq-lite/src/ietf/track.rs @@ -92,3 +92,52 @@ impl Decode for TrackStatusCode { Self::try_from(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue) } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + + fn encode_message(msg: &M, version: Version) -> Vec { + let mut buf = BytesMut::new(); + msg.encode_msg(&mut buf, version); + buf.to_vec() + } + + fn decode_message(bytes: &[u8], version: Version) -> Result { + let mut buf = bytes::Bytes::from(bytes.to_vec()); + M::decode_msg(&mut buf, version) + } + + #[test] + fn test_track_status_v14_round_trip() { + let msg = TrackStatus { + request_id: RequestId(1), + track_namespace: Path::new("test/ns"), + track_name: "video".into(), + }; + + let encoded = encode_message(&msg, Version::Draft14); + let decoded: TrackStatus = decode_message(&encoded, Version::Draft14).unwrap(); + + assert_eq!(decoded.request_id, RequestId(1)); + assert_eq!(decoded.track_namespace.as_str(), "test/ns"); + assert_eq!(decoded.track_name, "video"); + } + + #[test] + fn test_track_status_v15_round_trip() { + let msg = TrackStatus { + request_id: RequestId(1), + track_namespace: Path::new("test/ns"), + track_name: "video".into(), + }; + + let encoded = encode_message(&msg, Version::Draft15); + let decoded: TrackStatus = decode_message(&encoded, Version::Draft15).unwrap(); + + assert_eq!(decoded.request_id, RequestId(1)); + assert_eq!(decoded.track_namespace.as_str(), "test/ns"); + assert_eq!(decoded.track_name, "video"); + } +} From 2e50f8d245f18dfb1905fed4f207fea8aeb9a9f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Feb 2026 20:02:02 -0800 Subject: [PATCH 4/7] Redo the AI connection stuff so its simpler. --- Cargo.lock | 10 +- Cargo.toml | 4 +- js/lite/src/connection/connect.ts | 40 ++++-- js/lite/src/ietf/publisher.ts | 20 +-- js/lite/src/ietf/setup.ts | 13 +- js/lite/src/ietf/subscribe.ts | 15 +++ js/lite/src/ietf/subscriber.ts | 15 ++- js/lite/src/ietf/version.ts | 6 + rs/moq-lite/src/client.rs | 92 ++++++++------ rs/moq-lite/src/error.rs | 4 + rs/moq-lite/src/ietf/version.rs | 8 +- rs/moq-lite/src/lib.rs | 2 + rs/moq-lite/src/server.rs | 96 ++++++++------- rs/moq-lite/src/session.rs | 14 +-- rs/moq-lite/src/setup.rs | 195 ++++++++++++++---------------- rs/moq-lite/src/version.rs | 89 ++++++++++++++ rs/moq-native/src/client.rs | 47 ++++--- rs/moq-native/src/iroh.rs | 5 +- rs/moq-native/src/server.rs | 33 +++-- 19 files changed, 452 insertions(+), 256 deletions(-) create mode 100644 rs/moq-lite/src/version.rs diff --git a/Cargo.lock b/Cargo.lock index ca6470c14..0f0af0039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5375,9 +5375,9 @@ dependencies = [ [[package]] name = "web-transport-quinn" -version = "0.10.2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f44b4e68a3e7adb790793e24ec8b5923a610a8c2df1d6cd58849f9e4759d04" +checksum = "a98cafbfe6a6222996a07243a673bc3ea3e664fb5ea5217738900218ae62c28b" dependencies = [ "bytes", "futures", @@ -5389,15 +5389,15 @@ dependencies = [ "tokio", "tracing", "url", - "web-transport-proto 0.3.1", + "web-transport-proto 0.4.0", "web-transport-trait", ] [[package]] name = "web-transport-trait" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae5c857e6b426610648b39c6b48f9e66ae97b27b166d7c2f1ec369596548271" +checksum = "2615c30ed29953bb3727391850279a25c948c0b7a4ed2343d3a78e1d3cce2f7c" dependencies = [ "bytes", ] diff --git a/Cargo.toml b/Cargo.toml index a615d694c..fd0017faa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,8 +24,8 @@ serde = { version = "1", features = ["derive"] } tokio = "1.48" web-async = { version = "0.1.1", features = ["tracing"] } web-transport-iroh = "0.1.1" -web-transport-quinn = "0.10" -web-transport-trait = "0.3" +web-transport-quinn = "0.11" +web-transport-trait = "0.3.2" web-transport-ws = "0.2.2" [profile.dev] diff --git a/js/lite/src/connection/connect.ts b/js/lite/src/connection/connect.ts index 2b65bd70f..905e167e5 100644 --- a/js/lite/src/connection/connect.ts +++ b/js/lite/src/connection/connect.ts @@ -57,21 +57,34 @@ export async function connect(url: URL, props?: ConnectProps): Promise): Promise { +async function connectWebSocket(url: URL, delay: number, cancel: Promise): Promise { const timer = new Promise((resolve) => setTimeout(resolve, delay)); const active = await Promise.race([cancel, timer.then(() => true)]); diff --git a/js/lite/src/ietf/publisher.ts b/js/lite/src/ietf/publisher.ts index ed2445346..dddcf9072 100644 --- a/js/lite/src/ietf/publisher.ts +++ b/js/lite/src/ietf/publisher.ts @@ -14,10 +14,11 @@ import { type PublishNamespaceError, type PublishNamespaceOk, } from "./publish_namespace.ts"; -import type { RequestError, RequestOk } from "./request.ts"; +import { RequestError, type RequestOk } from "./request.ts"; import { type Subscribe, SubscribeError, SubscribeOk, type Unsubscribe } from "./subscribe.ts"; import type { SubscribeNamespace, UnsubscribeNamespace } from "./subscribe_namespace.ts"; import { TrackStatus, type TrackStatusRequest } from "./track.ts"; +import { Version } from "./version.ts"; /** * Handles publishing broadcasts using moq-transport protocol with lite-compatibility restrictions. @@ -86,12 +87,17 @@ export class Publisher { const broadcast = this.#broadcasts.get(name); if (!broadcast) { - const errorMsg = new SubscribeError( - msg.requestId, - 404, // Not found - "Broadcast not found", - ); - await this.#control.write(errorMsg); + if (this.#control.version === Version.DRAFT_15) { + const errorMsg = new RequestError(msg.requestId, 404, "Broadcast not found"); + await this.#control.write(errorMsg); + } else if (this.#control.version === Version.DRAFT_14) { + const errorMsg = new SubscribeError(msg.requestId, 404, "Broadcast not found"); + await this.#control.write(errorMsg); + } else { + const version: never = this.#control.version; + throw new Error(`unsupported version: ${version}`); + } + return; } diff --git a/js/lite/src/ietf/setup.ts b/js/lite/src/ietf/setup.ts index 118ed5d6f..86a29a7a0 100644 --- a/js/lite/src/ietf/setup.ts +++ b/js/lite/src/ietf/setup.ts @@ -19,6 +19,11 @@ export class ClientSetup { async #encode(w: Writer, version: IetfVersion): Promise { if (version === Version.DRAFT_15) { // Draft15: no versions list, just parameters + // Make sure versions is draft 15 only. + if (this.versions.length !== 1 || this.versions[0] !== Version.DRAFT_15) { + throw new Error("versions must be draft 15 only"); + } + await this.parameters.encode(w); } else if (version === Version.DRAFT_14) { await w.u53(this.versions.length); @@ -80,15 +85,15 @@ export class ServerSetup { this.parameters = parameters; } - async #encode(w: Writer, encodeVersion: IetfVersion): Promise { - if (encodeVersion === Version.DRAFT_15) { + async #encode(w: Writer, version: IetfVersion): Promise { + if (version === Version.DRAFT_15) { // Draft15: no version field, just parameters await this.parameters.encode(w); - } else if (encodeVersion === Version.DRAFT_14) { + } else if (version === Version.DRAFT_14) { await w.u53(this.version); await this.parameters.encode(w); } else { - const _: never = encodeVersion; + const _: never = version; throw new Error(`unsupported version: ${_}`); } } diff --git a/js/lite/src/ietf/subscribe.ts b/js/lite/src/ietf/subscribe.ts index 0c3ff108e..044d7501f 100644 --- a/js/lite/src/ietf/subscribe.ts +++ b/js/lite/src/ietf/subscribe.ts @@ -65,6 +65,21 @@ export class Subscribe { // v15: fields are in parameters const params = await MessageParameters.decode(r); const subscriberPriority = params.subscriberPriority ?? 128; + const groupOrder = params.groupOrder ?? GROUP_ORDER; + if (groupOrder > 2 || groupOrder === 0) { + throw new Error(`unknown group order: ${groupOrder}`); + } + + const forward = params.forward ?? true; + if (!forward) { + throw new Error(`unsupported forward value: ${forward}`); + } + + const filterType = params.subscriptionFilter ?? 0x2; + if (filterType !== 0x1 && filterType !== 0x2) { + throw new Error(`unsupported filter type: ${filterType}`); + } + return new Subscribe(requestId, trackNamespace, trackName, subscriberPriority); } else if (version === Version.DRAFT_14) { const subscriberPriority = await r.u8(); diff --git a/js/lite/src/ietf/subscriber.ts b/js/lite/src/ietf/subscriber.ts index 19421e294..0c0a9b589 100644 --- a/js/lite/src/ietf/subscriber.ts +++ b/js/lite/src/ietf/subscriber.ts @@ -9,7 +9,7 @@ import type * as Control from "./control.ts"; import { Frame, type Group as GroupMessage } from "./object.ts"; import { type Publish, type PublishDone, PublishError } from "./publish.ts"; import type { PublishNamespace, PublishNamespaceDone } from "./publish_namespace.ts"; -import type { RequestError, RequestOk } from "./request.ts"; +import { RequestError, type RequestOk } from "./request.ts"; import { Subscribe, type SubscribeError, type SubscribeOk, Unsubscribe } from "./subscribe.ts"; import { SubscribeNamespace, @@ -18,6 +18,7 @@ import { UnsubscribeNamespace, } from "./subscribe_namespace.ts"; import type { TrackStatus } from "./track.ts"; +import { Version } from "./version.ts"; /** * Handles subscribing to broadcasts using moq-transport protocol with lite-compatibility restrictions. @@ -245,8 +246,16 @@ export class Subscriber { async handlePublish(msg: Publish) { // TODO technically, we should send PUBLISH_OK if we had a SUBSCRIBE in flight for the same track. // Otherwise, the peer will SUBSCRIBE_ERROR because duplicate subscriptions are not allowed :( - const err = new PublishError(msg.requestId, 500, "publish not supported"); - await this.#control.write(err); + if (this.#control.version === Version.DRAFT_15) { + const err = new RequestError(msg.requestId, 500, "publish not supported"); + await this.#control.write(err); + } else if (this.#control.version === Version.DRAFT_14) { + const err = new PublishError(msg.requestId, 500, "publish not supported"); + await this.#control.write(err); + } else { + const version: never = this.#control.version; + throw new Error(`unsupported version: ${version}`); + } } /** diff --git a/js/lite/src/ietf/version.ts b/js/lite/src/ietf/version.ts index d40e6c196..a3badb842 100644 --- a/js/lite/src/ietf/version.ts +++ b/js/lite/src/ietf/version.ts @@ -23,6 +23,12 @@ export const Version = { export type Version = (typeof Version)[keyof typeof Version]; +// ALPN / WebTransport subprotocol identifiers for draft versions. +export const ALPN = { + DRAFT_14: "moq-00", + DRAFT_15: "moqt-15", +} as const; + /** * IETF protocol versions used by the ietf/ module. * Use this narrower type for version-branched encode/decode to get exhaustive matching. diff --git a/rs/moq-lite/src/client.rs b/rs/moq-lite/src/client.rs index d38b126b6..979cf28f1 100644 --- a/rs/moq-lite/src/client.rs +++ b/rs/moq-lite/src/client.rs @@ -2,8 +2,8 @@ // use std::sync::Arc; use crate::{ - Error, OriginConsumer, OriginProducer, Session, VERSIONS, - coding::{Decode, Encode, Stream}, + Error, NEGOTIATED, OriginConsumer, OriginProducer, Session, Version, + coding::{self, Decode, Encode, Stream}, ietf, lite, setup, }; @@ -43,7 +43,22 @@ impl Client { tracing::warn!("not publishing or consuming anything"); } - let mut stream = Stream::open(&session, setup::ServerKind::Ietf14).await?; + // If ALPN was used to negotiate the version, use the appropriate encoding. + // Default to IETF 14 if no ALPN was used and we'll negotiate the version later. + let (encoding, supported) = match session.protocol() { + Some(p) if p == ietf::ALPN_15 => ( + Version::Ietf(ietf::Version::Draft15), + vec![ietf::Version::Draft15.into()], + ), + Some(p) if p == ietf::ALPN_14 => ( + Version::Ietf(ietf::Version::Draft14), + vec![ietf::Version::Draft14.into()], + ), + None => (Version::Ietf(ietf::Version::Draft14), NEGOTIATED.to_vec()), + Some(_) => return Err(Error::UnknownAlpn), + }; + + let mut stream = Stream::open(&session, encoding).await?; let mut parameters = ietf::Parameters::default(); parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); @@ -51,10 +66,7 @@ impl Client { let parameters = parameters.encode_bytes(()); let client = setup::Client { - // Unfortunately, we have to pick a single draft range to support. - // moq-lite can support this handshake. - kind: setup::ClientKind::Ietf14, - versions: VERSIONS.into(), + versions: supported.clone().into(), parameters, }; @@ -65,36 +77,42 @@ impl Client { let mut server: setup::Server = stream.reader.decode().await?; tracing::trace!(?server, "received server setup"); - if let Ok(version) = lite::Version::try_from(server.version) { - let stream = stream.with_version(version); - lite::start( - session.clone(), - stream, - self.publish.clone(), - self.consume.clone(), - version, - ) - .await?; - } else if let Ok(version) = ietf::Version::try_from(server.version) { - // Decode the parameters to get the initial request ID. - let parameters = ietf::Parameters::decode(&mut server.parameters, version)?; - let request_id_max = - ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); - - let stream = stream.with_version(version); - ietf::start( - session.clone(), - stream, - request_id_max, - true, - self.publish.clone(), - self.consume.clone(), - version, - ) - .await?; - } else { - // unreachable, but just in case - return Err(Error::Version(client.versions, [server.version].into())); + let version = supported + .iter() + .find(|v| coding::Version::from(**v) == server.version) + .copied() + .ok_or_else(|| Error::Version(client.versions.clone(), supported.clone().into()))?; + + match version { + Version::Lite(version) => { + let stream = stream.with_version(version); + lite::start( + session.clone(), + stream, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } + Version::Ietf(version) => { + // Decode the parameters to get the initial request ID. + let parameters = ietf::Parameters::decode(&mut server.parameters, version)?; + let request_id_max = + ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); + + let stream = stream.with_version(version); + ietf::start( + session.clone(), + stream, + request_id_max, + true, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } } tracing::debug!(version = ?server.version, "connected"); diff --git a/rs/moq-lite/src/error.rs b/rs/moq-lite/src/error.rs index 6c9a6d4ad..30b0f7b37 100644 --- a/rs/moq-lite/src/error.rs +++ b/rs/moq-lite/src/error.rs @@ -80,6 +80,9 @@ pub enum Error { #[error("invalid role")] InvalidRole, + + #[error("unknown ALPN")] + UnknownAlpn, } impl Error { @@ -105,6 +108,7 @@ impl Error { Self::TooLarge => 18, Self::TooManyParameters => 19, Self::InvalidRole => 20, + Self::UnknownAlpn => 21, Self::App(app) => *app + 64, } } diff --git a/rs/moq-lite/src/ietf/version.rs b/rs/moq-lite/src/ietf/version.rs index 1f445fd27..315232264 100644 --- a/rs/moq-lite/src/ietf/version.rs +++ b/rs/moq-lite/src/ietf/version.rs @@ -1,6 +1,6 @@ use crate::coding; -pub const ALPN: &str = "moq-00"; +pub const ALPN_14: &str = "moq-00"; pub const ALPN_15: &str = "moqt-15"; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] @@ -29,9 +29,3 @@ impl From for coding::Version { Self(value as u64) } } - -impl Version { - pub const fn coding(self) -> coding::Version { - coding::Version(self as u64) - } -} diff --git a/rs/moq-lite/src/lib.rs b/rs/moq-lite/src/lib.rs index bed0e98bd..e4ee5d383 100644 --- a/rs/moq-lite/src/lib.rs +++ b/rs/moq-lite/src/lib.rs @@ -41,6 +41,7 @@ mod path; mod server; mod session; mod setup; +mod version; pub mod coding; pub mod ietf; @@ -52,3 +53,4 @@ pub use model::*; pub use path::*; pub use server::*; pub use session::*; +pub use version::*; diff --git a/rs/moq-lite/src/server.rs b/rs/moq-lite/src/server.rs index 6cb77c35e..706b0a1f6 100644 --- a/rs/moq-lite/src/server.rs +++ b/rs/moq-lite/src/server.rs @@ -2,7 +2,7 @@ // use std::sync::Arc; use crate::{ - Error, OriginConsumer, OriginProducer, Session, VERSIONS, + Error, NEGOTIATED, OriginConsumer, OriginProducer, Session, Version, coding::{Decode, Encode, Stream}, ietf, lite, setup, }; @@ -43,8 +43,21 @@ impl Server { tracing::warn!("not publishing or consuming anything"); } - // Accept with an initial version; we'll switch to the negotiated version later - let mut stream = Stream::accept(&session, ()).await?; + let (encoding, supported) = match session.protocol() { + Some(p) if p == ietf::ALPN_15 => ( + Version::Ietf(ietf::Version::Draft15), + vec![ietf::Version::Draft15.into()], + ), + Some(p) if p == ietf::ALPN_14 => ( + Version::Ietf(ietf::Version::Draft14), + vec![ietf::Version::Draft14.into()], + ), + None => (Version::Ietf(ietf::Version::Draft14), NEGOTIATED.to_vec()), + Some(_) => return Err(Error::UnknownAlpn), + }; + + let mut stream = Stream::accept(&session, encoding).await?; + let mut client: setup::Client = stream.reader.decode().await?; tracing::trace!(?client, "received client setup"); @@ -52,12 +65,12 @@ impl Server { let version = client .versions .iter() - .find(|v| VERSIONS.contains(v)) - .copied() - .ok_or_else(|| Error::Version(client.versions.clone(), VERSIONS.into()))?; + .flat_map(|v| Version::try_from(*v).ok()) + .find(|v| supported.contains(v)) + .ok_or_else(|| Error::Version(client.versions.clone(), supported.into()))?; // Only encode parameters if we're using the IETF draft because it has max_request_id - let parameters = if ietf::Version::try_from(version).is_ok() && client.kind == setup::ClientKind::Ietf14 { + let parameters = if version.is_ietf() { let mut parameters = ietf::Parameters::default(); parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); @@ -66,43 +79,44 @@ impl Server { lite::Parameters::default().encode_bytes(()) }; - let server = setup::Server { version, parameters }; + let server = setup::Server { + version: version.into(), + parameters, + }; tracing::trace!(?server, "sending server setup"); - - let mut stream = stream.with_version(client.kind.reply()); stream.writer.encode(&server).await?; - if let Ok(version) = lite::Version::try_from(version) { - let stream = stream.with_version(version); - lite::start( - session.clone(), - stream, - self.publish.clone(), - self.consume.clone(), - version, - ) - .await?; - } else if let Ok(version) = ietf::Version::try_from(version) { - // Decode the client's parameters to get their max request ID. - let parameters = ietf::Parameters::decode(&mut client.parameters, version)?; - let request_id_max = - ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); - - let stream = stream.with_version(version); - ietf::start( - session.clone(), - stream, - request_id_max, - false, - self.publish.clone(), - self.consume.clone(), - version, - ) - .await?; - } else { - // unreachable, but just in case - return Err(Error::Version(client.versions, VERSIONS.into())); - } + match version { + Version::Lite(version) => { + let stream = stream.with_version(version); + lite::start( + session.clone(), + stream, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } + Version::Ietf(version) => { + // Decode the client's parameters to get their max request ID. + let parameters = ietf::Parameters::decode(&mut client.parameters, version)?; + let request_id_max = + ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); + + let stream = stream.with_version(version); + ietf::start( + session.clone(), + stream, + request_id_max, + false, + self.publish.clone(), + self.consume.clone(), + version, + ) + .await?; + } + }; tracing::debug!(?version, "connected"); diff --git a/rs/moq-lite/src/session.rs b/rs/moq-lite/src/session.rs index 648b69371..5eec662b6 100644 --- a/rs/moq-lite/src/session.rs +++ b/rs/moq-lite/src/session.rs @@ -1,18 +1,6 @@ use std::{future::Future, pin::Pin, sync::Arc}; -use crate::{Error, coding, ietf, lite}; - -/// The versions of MoQ that are supported by this implementation. -/// -/// Ordered by preference, with the client's preference taking priority. -pub const VERSIONS: [coding::Version; 3] = [ - lite::Version::Draft02.coding(), - lite::Version::Draft01.coding(), - ietf::Version::Draft14.coding(), -]; - -/// The ALPN strings for supported versions. -pub const ALPNS: [&str; 2] = [lite::ALPN, ietf::ALPN]; +use crate::Error; /// A MoQ transport session, wrapping a WebTransport connection. /// diff --git a/rs/moq-lite/src/setup.rs b/rs/moq-lite/src/setup.rs index 5f800e491..246906243 100644 --- a/rs/moq-lite/src/setup.rs +++ b/rs/moq-lite/src/setup.rs @@ -1,63 +1,50 @@ use bytes::Bytes; -use crate::coding::{Decode, DecodeError, Encode, Sizer, Version, Versions}; -use num_enum::{IntoPrimitive, TryFromPrimitive}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive, IntoPrimitive)] -#[repr(u64)] -pub enum ClientKind { - // This varint ID follow by the varint size. - Lite = 0x0, - // This varint ID followed by a varint size - // Valid until draft 10 - Ietf7 = 0x40, - // This varint ID followed by a u16 size - // Valid until draft 15 - Ietf14 = 0x20, -} +use crate::{ + coding::{self, Decode, DecodeError, Encode, Sizer}, + ietf, lite, + version::Version, +}; -impl Encode for ClientKind { - fn encode(&self, w: &mut W, version: V) { - u64::from(*self).encode(w, version); - } -} - -impl Decode for ClientKind { - fn decode(r: &mut R, version: V) -> Result { - Self::try_from(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue) - } -} - -impl ClientKind { - pub fn reply(self) -> ServerKind { - match self { - Self::Lite => ServerKind::Lite, - Self::Ietf7 => ServerKind::Ietf7, - Self::Ietf14 => ServerKind::Ietf14, - } - } -} +const CLIENT_SETUP: u8 = 0x20; +const SERVER_SETUP: u8 = 0x21; /// A version-agnostic setup message sent by the client. #[derive(Debug, Clone)] pub struct Client { - /// The first byte of the setup message. - pub kind: ClientKind, - /// The list of supported versions in preferred order. - pub versions: Versions, + pub versions: coding::Versions, /// Parameters, unparsed because the IETF draft changed the encoding. pub parameters: Bytes, } -impl Decode for Client { +impl Client { + fn encode_inner(&self, w: &mut W, v: Version) { + match v { + Version::Ietf(ietf::Version::Draft15) => { + // Draft15: no versions list, parameters only. + assert_eq!(self.versions, coding::Versions::from([ietf::Version::Draft15.into()])); + } + Version::Ietf(ietf::Version::Draft14) + | Version::Lite(lite::Version::Draft02) + | Version::Lite(lite::Version::Draft01) => self.versions.encode(w, v), + }; + w.put_slice(&self.parameters); + } +} + +impl Decode for Client { /// Decode a client setup message. - fn decode(r: &mut R, v: V) -> Result { - let kind = ClientKind::decode(r, v.clone())?; - let size = match kind { - ClientKind::Lite | ClientKind::Ietf7 => u64::decode(r, v.clone())? as usize, - ClientKind::Ietf14 => u16::decode(r, v.clone())? as usize, + fn decode(r: &mut R, v: Version) -> Result { + let kind = u8::decode(r, v)?; + if kind != CLIENT_SETUP { + return Err(DecodeError::InvalidValue); + } + + let size = match v { + Version::Ietf(ietf::Version::Draft15 | ietf::Version::Draft14) => u16::decode(r, v)? as usize, + Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => u64::decode(r, v)? as usize, }; if r.remaining() < size { @@ -65,53 +52,40 @@ impl Decode for Client { } let mut msg = r.copy_to_bytes(size); - let versions = Versions::decode(&mut msg, v)?; + + let versions = match v { + Version::Ietf(ietf::Version::Draft15) => { + // Draft15: no versions list, parameters only. + coding::Versions::from([ietf::Version::Draft15.into()]) + } + Version::Ietf(ietf::Version::Draft14) + | Version::Lite(lite::Version::Draft02) + | Version::Lite(lite::Version::Draft01) => coding::Versions::decode(&mut msg, v)?, + }; Ok(Self { - kind, versions, parameters: msg, }) } } -impl Encode for Client { +impl Encode for Client { /// Encode a client setup message. - fn encode(&self, w: &mut W, v: V) { - self.kind.encode(w, v.clone()); + fn encode(&self, w: &mut W, v: Version) { + CLIENT_SETUP.encode(w, v); let mut sizer = Sizer::default(); - self.versions.encode(&mut sizer, v.clone()); - - let size = sizer.size + self.parameters.len(); + self.encode_inner(&mut sizer, v); + let size = sizer.size; - match self.kind { - ClientKind::Lite | ClientKind::Ietf7 => (size as u64).encode(w, v.clone()), - ClientKind::Ietf14 => u16::try_from(size).expect("message be huge").encode(w, v.clone()), + match v { + Version::Ietf(ietf::Version::Draft15 | ietf::Version::Draft14) => { + u16::try_from(size).expect("message be huge").encode(w, v) + } + Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => (size as u64).encode(w, v), } - - self.versions.encode(w, v); - w.put_slice(&self.parameters); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive, IntoPrimitive)] -#[repr(u64)] -pub enum ServerKind { - Lite = 0x0, // NOTE: Not actually encoded - Ietf7 = 0x41, - Ietf14 = 0x21, -} - -impl Encode<()> for ServerKind { - fn encode(&self, w: &mut W, version: ()) { - u64::from(*self).encode(w, version); - } -} - -impl Decode<()> for ServerKind { - fn decode(r: &mut R, version: ()) -> Result { - Self::try_from(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue) + self.encode_inner(w, v); } } @@ -119,44 +93,56 @@ impl Decode<()> for ServerKind { #[derive(Debug, Clone)] pub struct Server { /// The list of supported versions in preferred order. - pub version: Version, + pub version: coding::Version, /// Supported extensions. pub parameters: Bytes, } -impl Encode for Server { - fn encode(&self, w: &mut W, v: ServerKind) { - if v != ServerKind::Lite { - v.encode(w, ()); - } +impl Server { + fn encode_inner(&self, w: &mut W, v: Version) { + match v { + Version::Ietf(ietf::Version::Draft15) => { + // Draft15: No version field, parameters only. + assert_eq!(self.version, ietf::Version::Draft15.into()); + } + Version::Ietf(ietf::Version::Draft14) + | Version::Lite(lite::Version::Draft02) + | Version::Lite(lite::Version::Draft01) => self.version.encode(w, v), + }; + w.put_slice(&self.parameters); + } +} + +impl Encode for Server { + fn encode(&self, w: &mut W, v: Version) { + SERVER_SETUP.encode(w, v); let mut sizer = Sizer::default(); - self.version.encode(&mut sizer, v); - let size = sizer.size + self.parameters.len(); + self.encode_inner(&mut sizer, v); + let size = sizer.size; match v { - ServerKind::Lite | ServerKind::Ietf7 => (size as u64).encode(w, v), - ServerKind::Ietf14 => u16::try_from(size).expect("message be huge").encode(w, v), + Version::Ietf(ietf::Version::Draft15 | ietf::Version::Draft14) => { + u16::try_from(size).expect("message be huge").encode(w, v) + } + Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => (size as u64).encode(w, v), } - self.version.encode(w, v); - w.put_slice(&self.parameters); + self.encode_inner(w, v); } } -impl Decode for Server { - fn decode(r: &mut R, v: ServerKind) -> Result { - if v != ServerKind::Lite { - let kind = ServerKind::decode(r, ())?; - if kind != v { - return Err(DecodeError::InvalidValue); - } +impl Decode for Server { + fn decode(r: &mut R, v: Version) -> Result { + let kind = u8::decode(r, v)?; + if kind != SERVER_SETUP { + return Err(DecodeError::InvalidValue); } let size = match v { - ServerKind::Lite | ServerKind::Ietf7 => u64::decode(r, v)? as usize, - ServerKind::Ietf14 => u16::decode(r, v)? as usize, + Version::Ietf(ietf::Version::Draft15 | ietf::Version::Draft14) => u16::decode(r, v)? as usize, + Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => u64::decode(r, v)? as usize, }; if r.remaining() < size { @@ -164,7 +150,12 @@ impl Decode for Server { } let mut msg = r.copy_to_bytes(size); - let version = Version::decode(&mut msg, v)?; + let version = match v { + Version::Ietf(ietf::Version::Draft15) => v.into(), + Version::Ietf(ietf::Version::Draft14) + | Version::Lite(lite::Version::Draft02) + | Version::Lite(lite::Version::Draft01) => coding::Version::decode(&mut msg, v)?, + }; Ok(Self { version, diff --git a/rs/moq-lite/src/version.rs b/rs/moq-lite/src/version.rs new file mode 100644 index 000000000..7bf2d56a6 --- /dev/null +++ b/rs/moq-lite/src/version.rs @@ -0,0 +1,89 @@ +use crate::{coding, ietf, lite}; + +/// The versions of MoQ that are negotiated. +/// +/// Ordered by preference, with the client's preference taking priority. +pub(crate) const NEGOTIATED: [Version; 3] = [ + Version::Lite(lite::Version::Draft02), + Version::Lite(lite::Version::Draft01), + Version::Ietf(ietf::Version::Draft14), +]; + +/// The ALPN strings for supported versions. +const ALPNS: [&str; 3] = [lite::ALPN, ietf::ALPN_14, ietf::ALPN_15]; + +// Return the ALPN strings for supported versions. +// This is a function so we can avoid semver bumps. +pub fn alpns() -> &'static [&'static str] { + &ALPNS +} + +// A combination of ietf::Version and lite::Version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Version { + Ietf(ietf::Version), + Lite(lite::Version), +} + +impl Version { + pub fn is_ietf(self) -> bool { + matches!(self, Self::Ietf(_)) + } + + pub fn is_lite(self) -> bool { + matches!(self, Self::Lite(_)) + } +} + +impl From for Version { + fn from(value: ietf::Version) -> Self { + Self::Ietf(value) + } +} + +impl From for Version { + fn from(value: lite::Version) -> Self { + Self::Lite(value) + } +} + +impl TryFrom for Version { + type Error = (); + + fn try_from(value: coding::Version) -> Result { + ietf::Version::try_from(value) + .map(Self::Ietf) + .or_else(|_| lite::Version::try_from(value).map(Self::Lite)) + } +} + +impl coding::Decode for Version { + fn decode(r: &mut R, version: V) -> Result { + coding::Version::decode(r, version).and_then(|v| v.try_into().map_err(|_| coding::DecodeError::InvalidValue)) + } +} + +impl coding::Encode for Version { + fn encode(&self, w: &mut W, v: V) { + match self { + Self::Ietf(version) => coding::Version::from(*version).encode(w, v), + Self::Lite(version) => coding::Version::from(*version).encode(w, v), + } + } +} + +impl From for coding::Version { + fn from(value: Version) -> Self { + match value { + Version::Ietf(version) => version.into(), + Version::Lite(version) => version.into(), + } + } +} + +impl From> for coding::Versions { + fn from(value: Vec) -> Self { + let inner: Vec = value.into_iter().map(|v| v.into()).collect(); + coding::Versions::from(inner) + } +} diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index c168c6019..30bb76752 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -309,30 +309,46 @@ impl Client { url.set_scheme("https").expect("failed to set scheme"); } - let alpn = match url.scheme() { - "https" => web_transport_quinn::ALPN, - "moql" => moq_lite::lite::ALPN, - "moqt" => moq_lite::ietf::ALPN, - _ => anyhow::bail!("url scheme must be 'http', 'https', or 'moql'"), + let alpns: Vec = match url.scheme() { + "https" => vec![web_transport_quinn::ALPN.to_string()], + "moqt" => moq_lite::alpns().into_iter().map(|alpn| alpn.to_string()).collect(), + alpn if moq_lite::alpns().contains(&alpn) => vec![alpn.to_string()], + _ => anyhow::bail!("url scheme must be 'http', 'https', or 'moqt'"), }; - // TODO support connecting to both ALPNs at the same time - config.alpn_protocols = vec![alpn.as_bytes().to_vec()]; + config.alpn_protocols = alpns.iter().map(|alpn| alpn.as_bytes().to_vec()).collect(); config.key_log = Arc::new(rustls::KeyLogFile::new()); let config: quinn::crypto::rustls::QuicClientConfig = config.try_into()?; let mut config = quinn::ClientConfig::new(Arc::new(config)); config.transport_config(self.transport.clone()); - tracing::debug!(%url, %ip, %alpn, "connecting"); + tracing::debug!(%url, %ip, alpns = ?alpns, "connecting"); let connection = self.quic.connect_with(config, ip, &host)?.await?; tracing::Span::current().record("id", connection.stable_id()); - let session = match alpn { - web_transport_quinn::ALPN => web_transport_quinn::Session::connect(connection, url).await?, - moq_lite::lite::ALPN | moq_lite::ietf::ALPN => web_transport_quinn::Session::raw(connection, url), - _ => unreachable!("ALPN was checked above"), + let mut request = web_transport_quinn::proto::ConnectRequest::new(url); + + let session = if request.url.scheme() == "https" { + let alpns: Vec = moq_lite::alpns().iter().map(|alpn| alpn.to_string()).collect(); + let request = request.with_protocols(alpns); + web_transport_quinn::Session::connect(connection, request).await? + } else { + request = request.with_protocols(alpns); + + let mut response = + web_transport_quinn::proto::ConnectResponse::new(web_transport_quinn::http::StatusCode::OK); + if let Some(negotiated_alpn) = connection + .handshake_data() + .and_then(|data| data.downcast::().ok()) + .and_then(|data| data.protocol) + .and_then(|proto| String::from_utf8(proto).ok()) + { + response = response.with_protocol(negotiated_alpn); + } + + web_transport_quinn::Session::raw(connection, request, response) }; Ok(session) @@ -343,7 +359,6 @@ impl Client { let host = url.host_str().context("missing hostname")?.to_string(); let port = url.port().unwrap_or_else(|| match url.scheme() { - "https" | "wss" | "moql" | "moqt" => 443, "http" | "ws" => 80, _ => 443, }); @@ -366,7 +381,7 @@ impl Client { url.set_scheme("ws").expect("failed to set scheme"); false } - "https" | "moql" | "moqt" => { + "https" | "moqt" => { url.set_scheme("wss").expect("failed to set scheme"); true } @@ -412,9 +427,11 @@ impl Client { #[cfg(feature = "iroh")] async fn connect_iroh(&self, url: Url) -> anyhow::Result { let endpoint = self.iroh.as_ref().context("Iroh support is not enabled")?; + // TODO Suupport multiple ALPNs let alpn = match url.scheme() { "moql+iroh" | "iroh" => moq_lite::lite::ALPN, - "moqt+iroh" => moq_lite::ietf::ALPN, + "moqt+iroh" => moq_lite::ietf::ALPN_14, + "moqt-15+iroh" => moq_lite::ietf::ALPN_15, "h3+iroh" => web_transport_iroh::ALPN_H3, _ => anyhow::bail!("Invalid URL: unknown scheme"), }; diff --git a/rs/moq-native/src/iroh.rs b/rs/moq-native/src/iroh.rs index 8178ebdd5..9ef5d6c8a 100644 --- a/rs/moq-native/src/iroh.rs +++ b/rs/moq-native/src/iroh.rs @@ -70,7 +70,8 @@ impl IrohEndpointConfig { let mut builder = IrohEndpoint::builder().secret_key(secret_key).alpns(vec![ web_transport_iroh::ALPN_H3.as_bytes().to_vec(), moq_lite::lite::ALPN.as_bytes().to_vec(), - moq_lite::ietf::ALPN.as_bytes().to_vec(), + moq_lite::ietf::ALPN_14.as_bytes().to_vec(), + moq_lite::ietf::ALPN_15.as_bytes().to_vec(), ]); if let Some(addr) = self.bind_v4 { builder = builder.bind_addr_v4(addr); @@ -87,7 +88,7 @@ impl IrohEndpointConfig { } /// URL schemes supported for connecting to iroh endpoints. -pub const IROH_SCHEMES: [&str; 4] = ["iroh", "moql+iroh", "moqt+iroh", "h3+iroh"]; +pub const IROH_SCHEMES: [&str; 5] = ["iroh", "moql+iroh", "moqt+iroh", "moqt-15+iroh", "h3+iroh"]; /// Returns `true` if `url` has a scheme included in [`IROH_SCHEMES`]. pub fn is_iroh_url(url: &Url) -> bool { diff --git a/rs/moq-native/src/server.rs b/rs/moq-native/src/server.rs index 1c7a67c5a..916387091 100644 --- a/rs/moq-native/src/server.rs +++ b/rs/moq-native/src/server.rs @@ -133,7 +133,8 @@ impl Server { tls.alpn_protocols = vec![ web_transport_quinn::ALPN.as_bytes().to_vec(), moq_lite::lite::ALPN.as_bytes().to_vec(), - moq_lite::ietf::ALPN.as_bytes().to_vec(), + moq_lite::ietf::ALPN_14.as_bytes().to_vec(), + moq_lite::ietf::ALPN_15.as_bytes().to_vec(), ]; tls.key_log = Arc::new(rustls::KeyLogFile::new()); @@ -308,7 +309,7 @@ impl Server { kind: RequestKind::WebTransport(request), }) } - moq_lite::lite::ALPN | moq_lite::ietf::ALPN => Ok(Request { + moq_lite::lite::ALPN | moq_lite::ietf::ALPN_14 | moq_lite::ietf::ALPN_15 => Ok(Request { server: server.clone(), kind: RequestKind::Quic(QuicRequest::accept(conn)), }), @@ -332,7 +333,7 @@ impl Server { kind: RequestKind::IrohWebTransport(request), }) } - moq_lite::lite::ALPN | moq_lite::ietf::ALPN => { + moq_lite::lite::ALPN | moq_lite::ietf::ALPN_14 | moq_lite::ietf::ALPN_15 => { let request = IrohQuicRequest::accept(conn); Ok(Request { server: server.clone(), @@ -376,7 +377,7 @@ impl Request { /// Reject the session, returning your favorite HTTP status code. pub async fn reject(self, status: http::StatusCode) -> anyhow::Result<()> { match self.kind { - RequestKind::WebTransport(request) => request.close(status).await?, + RequestKind::WebTransport(request) => request.reject(status).await?, RequestKind::Quic(request) => request.close(status), #[cfg(feature = "iroh")] RequestKind::IrohWebTransport(request) => request.close(status).await?, @@ -405,7 +406,21 @@ impl Request { /// Accept the session, performing rest of the MoQ handshake. pub async fn accept(self) -> anyhow::Result { let session = match self.kind { - RequestKind::WebTransport(request) => self.server.accept(request.ok().await?).await?, + RequestKind::WebTransport(request) => { + let mut response = web_transport_quinn::proto::ConnectResponse::new(http::StatusCode::OK); + + // Choose the ALPN based on our supported versions. + if let Some(alpn) = request + .protocols + .iter() + .find(|alpn| moq_lite::alpns().contains(&alpn.as_str())) + { + response = response.with_protocol(alpn.as_str()); + } + + let session = request.respond(response).await?; + self.server.accept(session).await? + } RequestKind::Quic(request) => self.server.accept(request.ok()).await?, #[cfg(feature = "iroh")] RequestKind::IrohWebTransport(request) => self.server.accept(request.ok().await?).await?, @@ -418,7 +433,7 @@ impl Request { /// Returns the URL provided by the client. pub fn url(&self) -> Option<&Url> { match &self.kind { - RequestKind::WebTransport(request) => Some(request.url()), + RequestKind::WebTransport(request) => Some(&request.url), #[cfg(feature = "iroh")] RequestKind::IrohWebTransport(request) => Some(request.url()), _ => None, @@ -445,7 +460,11 @@ impl QuicRequest { /// Accept the session, returning a 200 OK if using WebTransport. pub fn ok(self) -> web_transport_quinn::Session { - web_transport_quinn::Session::raw(self.connection, self.url) + // TODO add support for ALPN negotiation + let request = web_transport_quinn::proto::ConnectRequest::new(self.url); + let response = web_transport_quinn::proto::ConnectResponse::new(http::StatusCode::OK); + + web_transport_quinn::Session::raw(self.connection, request, response) } /// Returns the URL provided by the client. From c61e30c47f87dd77019d1ee188c603fac19b7641 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 11 Feb 2026 12:39:13 -0800 Subject: [PATCH 5/7] Bot review. --- js/lite/src/connection/connect.ts | 4 ++-- js/lite/src/ietf/parameters.ts | 19 +++++++++++++++++++ js/lite/src/ietf/publish.ts | 15 ++++++++++++++- js/lite/src/ietf/subscribe.ts | 12 +++++++++--- rs/moq-lite/src/client.rs | 7 +++++-- rs/moq-lite/src/ietf/fetch.rs | 12 ++++++++++-- rs/moq-lite/src/ietf/group.rs | 10 ++++++++++ rs/moq-lite/src/ietf/publish.rs | 14 +++++++++++--- rs/moq-lite/src/ietf/subscribe.rs | 12 ++++++++---- rs/moq-lite/src/setup.rs | 4 ++-- rs/moq-native/src/client.rs | 6 +++--- rs/moq-native/src/server.rs | 14 ++++++++++++-- 12 files changed, 105 insertions(+), 24 deletions(-) diff --git a/js/lite/src/connection/connect.ts b/js/lite/src/connection/connect.ts index 905e167e5..b320afbfa 100644 --- a/js/lite/src/connection/connect.ts +++ b/js/lite/src/connection/connect.ts @@ -97,7 +97,7 @@ export async function connect(url: URL, props?: ConnectProps): Promise 2 || groupOrder === 0) { + let groupOrder = params.groupOrder ?? GROUP_ORDER; + if (groupOrder > 2) { throw new Error(`unknown group order: ${groupOrder}`); } + if (groupOrder === 0) { + groupOrder = GROUP_ORDER; // default to descending + } const forward = params.forward ?? true; if (!forward) { @@ -84,10 +87,13 @@ export class Subscribe { } else if (version === Version.DRAFT_14) { const subscriberPriority = await r.u8(); - const groupOrder = await r.u8(); + let groupOrder = await r.u8(); if (groupOrder > 2) { throw new Error(`unknown group order: ${groupOrder}`); } + if (groupOrder === 0) { + groupOrder = GROUP_ORDER; // default to descending + } const forward = await r.bool(); if (!forward) { diff --git a/rs/moq-lite/src/client.rs b/rs/moq-lite/src/client.rs index 979cf28f1..11e296783 100644 --- a/rs/moq-lite/src/client.rs +++ b/rs/moq-lite/src/client.rs @@ -98,8 +98,11 @@ impl Client { Version::Ietf(version) => { // Decode the parameters to get the initial request ID. let parameters = ietf::Parameters::decode(&mut server.parameters, version)?; - let request_id_max = - ietf::RequestId(parameters.get_varint(ietf::ParameterVarInt::MaxRequestId).unwrap_or(0)); + let request_id_max = ietf::RequestId( + parameters + .get_varint(ietf::ParameterVarInt::MaxRequestId) + .unwrap_or(u32::MAX as u64), + ); let stream = stream.with_version(version); ietf::start( diff --git a/rs/moq-lite/src/ietf/fetch.rs b/rs/moq-lite/src/ietf/fetch.rs index 2c41ed768..4230e2a1d 100644 --- a/rs/moq-lite/src/ietf/fetch.rs +++ b/rs/moq-lite/src/ietf/fetch.rs @@ -154,7 +154,11 @@ impl Message for Fetch<'_> { let subscriber_priority = params.subscriber_priority().unwrap_or(128); let group_order = match params.group_order() { - Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + Some(v) => u8::try_from(v) + .ok() + .and_then(|v| GroupOrder::try_from(v).ok()) + .map(GroupOrder::any_to_descending) + .unwrap_or(GroupOrder::Descending), None => GroupOrder::Descending, }; @@ -222,7 +226,11 @@ impl Message for FetchOk { let params = MessageParameters::decode(buf, version)?; let group_order = match params.group_order() { - Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + Some(v) => u8::try_from(v) + .ok() + .and_then(|v| GroupOrder::try_from(v).ok()) + .map(GroupOrder::any_to_descending) + .unwrap_or(GroupOrder::Descending), None => GroupOrder::Descending, }; diff --git a/rs/moq-lite/src/ietf/group.rs b/rs/moq-lite/src/ietf/group.rs index 9c0017e65..04677562b 100644 --- a/rs/moq-lite/src/ietf/group.rs +++ b/rs/moq-lite/src/ietf/group.rs @@ -10,6 +10,16 @@ pub enum GroupOrder { Descending = 0x2, } +impl GroupOrder { + /// Map `Any` (0x0) to `Descending`, leaving other values unchanged. + pub fn any_to_descending(self) -> Self { + match self { + Self::Any => Self::Descending, + other => other, + } + } +} + impl Encode for GroupOrder { fn encode(&self, w: &mut W, version: V) { u8::from(*self).encode(w, version); diff --git a/rs/moq-lite/src/ietf/publish.rs b/rs/moq-lite/src/ietf/publish.rs index 9a0032417..264bf9246 100644 --- a/rs/moq-lite/src/ietf/publish.rs +++ b/rs/moq-lite/src/ietf/publish.rs @@ -228,7 +228,11 @@ impl Message for Publish<'_> { let params = MessageParameters::decode(r, version)?; let group_order = match params.group_order() { - Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + Some(v) => u8::try_from(v) + .ok() + .and_then(|v| GroupOrder::try_from(v).ok()) + .map(GroupOrder::any_to_descending) + .unwrap_or(GroupOrder::Descending), None => GroupOrder::Descending, }; let largest_location = params.largest_object(); @@ -270,7 +274,7 @@ impl Message for PublishOk { self.subscriber_priority.encode(w, version); self.group_order.encode(w, version); self.filter_type.encode(w, version); - assert!( + debug_assert!( matches!(self.filter_type, FilterType::LargestObject | FilterType::NextGroup), "absolute subscribe not supported" ); @@ -325,7 +329,11 @@ impl Message for PublishOk { let forward = params.forward().unwrap_or(true); let subscriber_priority = params.subscriber_priority().unwrap_or(128); let group_order = match params.group_order() { - Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + Some(v) => u8::try_from(v) + .ok() + .and_then(|v| GroupOrder::try_from(v).ok()) + .map(GroupOrder::any_to_descending) + .unwrap_or(GroupOrder::Descending), None => GroupOrder::Descending, }; let filter_type = params.subscription_filter().unwrap_or(FilterType::LargestObject); diff --git a/rs/moq-lite/src/ietf/subscribe.rs b/rs/moq-lite/src/ietf/subscribe.rs index d39cf3d41..b404fd045 100644 --- a/rs/moq-lite/src/ietf/subscribe.rs +++ b/rs/moq-lite/src/ietf/subscribe.rs @@ -92,7 +92,11 @@ impl Message for Subscribe<'_> { let subscriber_priority = params.subscriber_priority().unwrap_or(128); let group_order = match params.group_order() { - Some(v) => GroupOrder::try_from(v as u8).unwrap_or(GroupOrder::Descending), + Some(v) => u8::try_from(v) + .ok() + .and_then(|v| GroupOrder::try_from(v).ok()) + .map(GroupOrder::any_to_descending) + .unwrap_or(GroupOrder::Descending), None => GroupOrder::Descending, }; let filter_type = params.subscription_filter().unwrap_or(FilterType::LargestObject); @@ -117,10 +121,10 @@ impl Message for Subscribe<'_> { match version { Version::Draft14 => { self.subscriber_priority.encode(w, version); - GroupOrder::Descending.encode(w, version); + self.group_order.encode(w, version); true.encode(w, version); // forward - assert!( + debug_assert!( !matches!(self.filter_type, FilterType::AbsoluteStart | FilterType::AbsoluteRange), "Absolute subscribe not supported" ); @@ -131,7 +135,7 @@ impl Message for Subscribe<'_> { Version::Draft15 => { let mut params = MessageParameters::default(); params.set_subscriber_priority(self.subscriber_priority); - params.set_group_order(u8::from(GroupOrder::Descending) as u64); + params.set_group_order(u8::from(self.group_order) as u64); params.set_forward(true); params.set_subscription_filter(self.filter_type); params.encode(w, version); diff --git a/rs/moq-lite/src/setup.rs b/rs/moq-lite/src/setup.rs index 246906243..ff022af68 100644 --- a/rs/moq-lite/src/setup.rs +++ b/rs/moq-lite/src/setup.rs @@ -81,7 +81,7 @@ impl Encode for Client { match v { Version::Ietf(ietf::Version::Draft15 | ietf::Version::Draft14) => { - u16::try_from(size).expect("message be huge").encode(w, v) + u16::try_from(size).expect("message too large for u16").encode(w, v) } Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => (size as u64).encode(w, v), } @@ -124,7 +124,7 @@ impl Encode for Server { match v { Version::Ietf(ietf::Version::Draft15 | ietf::Version::Draft14) => { - u16::try_from(size).expect("message be huge").encode(w, v) + u16::try_from(size).expect("message too large for u16").encode(w, v) } Version::Lite(lite::Version::Draft02 | lite::Version::Draft01) => (size as u64).encode(w, v), } diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 30bb76752..fe2e98392 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -311,9 +311,9 @@ impl Client { let alpns: Vec = match url.scheme() { "https" => vec![web_transport_quinn::ALPN.to_string()], - "moqt" => moq_lite::alpns().into_iter().map(|alpn| alpn.to_string()).collect(), + "moqt" => moq_lite::alpns().iter().map(|alpn| alpn.to_string()).collect(), alpn if moq_lite::alpns().contains(&alpn) => vec![alpn.to_string()], - _ => anyhow::bail!("url scheme must be 'http', 'https', or 'moqt'"), + _ => anyhow::bail!("url scheme must be 'http', 'https', 'moqt', or a recognized MoQ ALPN"), }; config.alpn_protocols = alpns.iter().map(|alpn| alpn.as_bytes().to_vec()).collect(); @@ -427,7 +427,7 @@ impl Client { #[cfg(feature = "iroh")] async fn connect_iroh(&self, url: Url) -> anyhow::Result { let endpoint = self.iroh.as_ref().context("Iroh support is not enabled")?; - // TODO Suupport multiple ALPNs + // TODO Support multiple ALPNs let alpn = match url.scheme() { "moql+iroh" | "iroh" => moq_lite::lite::ALPN, "moqt+iroh" => moq_lite::ietf::ALPN_14, diff --git a/rs/moq-native/src/server.rs b/rs/moq-native/src/server.rs index 916387091..f87ab9358 100644 --- a/rs/moq-native/src/server.rs +++ b/rs/moq-native/src/server.rs @@ -460,9 +460,19 @@ impl QuicRequest { /// Accept the session, returning a 200 OK if using WebTransport. pub fn ok(self) -> web_transport_quinn::Session { - // TODO add support for ALPN negotiation let request = web_transport_quinn::proto::ConnectRequest::new(self.url); - let response = web_transport_quinn::proto::ConnectResponse::new(http::StatusCode::OK); + let mut response = web_transport_quinn::proto::ConnectResponse::new(http::StatusCode::OK); + + // Propagate the negotiated ALPN so session.protocol() works for version negotiation. + if let Some(alpn) = self + .connection + .handshake_data() + .and_then(|data| data.downcast::().ok()) + .and_then(|data| data.protocol) + .and_then(|proto| String::from_utf8(proto).ok()) + { + response = response.with_protocol(alpn); + } web_transport_quinn::Session::raw(self.connection, request, response) } From 9258bce02621598097e310d39d4d9a3a547e9cd4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 11 Feb 2026 13:20:21 -0800 Subject: [PATCH 6/7] works --- Cargo.toml | 20 ++++++++++---------- rs/moq-lite/src/client.rs | 3 ++- rs/moq-lite/src/error.rs | 6 +++--- rs/moq-lite/src/server.rs | 3 ++- rs/moq-native/src/quiche.rs | 5 +---- rs/moq-native/src/quinn.rs | 5 +---- 6 files changed, 19 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 18cd1e5c1..e465d7570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [workspace] members = [ - "rs/hang", - "rs/libmoq", - "rs/moq-cli", - "rs/moq-clock", - "rs/moq-lite", - "rs/moq-mux", - "rs/moq-native", - "rs/moq-relay", - "rs/moq-token", - "rs/moq-token-cli", + "rs/hang", + "rs/libmoq", + "rs/moq-cli", + "rs/moq-clock", + "rs/moq-lite", + "rs/moq-mux", + "rs/moq-native", + "rs/moq-relay", + "rs/moq-token", + "rs/moq-token-cli", ] resolver = "2" diff --git a/rs/moq-lite/src/client.rs b/rs/moq-lite/src/client.rs index 979cf28f1..72c7e28c8 100644 --- a/rs/moq-lite/src/client.rs +++ b/rs/moq-lite/src/client.rs @@ -54,8 +54,9 @@ impl Client { Version::Ietf(ietf::Version::Draft14), vec![ietf::Version::Draft14.into()], ), + Some(p) if p == lite::ALPN => (Version::Ietf(ietf::Version::Draft14), NEGOTIATED.to_vec()), None => (Version::Ietf(ietf::Version::Draft14), NEGOTIATED.to_vec()), - Some(_) => return Err(Error::UnknownAlpn), + Some(p) => return Err(Error::UnknownAlpn(p.to_string())), }; let mut stream = Stream::open(&session, encoding).await?; diff --git a/rs/moq-lite/src/error.rs b/rs/moq-lite/src/error.rs index 30b0f7b37..a914f4eea 100644 --- a/rs/moq-lite/src/error.rs +++ b/rs/moq-lite/src/error.rs @@ -81,8 +81,8 @@ pub enum Error { #[error("invalid role")] InvalidRole, - #[error("unknown ALPN")] - UnknownAlpn, + #[error("unknown ALPN: {0}")] + UnknownAlpn(String), } impl Error { @@ -108,7 +108,7 @@ impl Error { Self::TooLarge => 18, Self::TooManyParameters => 19, Self::InvalidRole => 20, - Self::UnknownAlpn => 21, + Self::UnknownAlpn(_) => 21, Self::App(app) => *app + 64, } } diff --git a/rs/moq-lite/src/server.rs b/rs/moq-lite/src/server.rs index 706b0a1f6..ae28bb475 100644 --- a/rs/moq-lite/src/server.rs +++ b/rs/moq-lite/src/server.rs @@ -52,8 +52,9 @@ impl Server { Version::Ietf(ietf::Version::Draft14), vec![ietf::Version::Draft14.into()], ), + Some(p) if p == lite::ALPN => (Version::Ietf(ietf::Version::Draft14), NEGOTIATED.to_vec()), None => (Version::Ietf(ietf::Version::Draft14), NEGOTIATED.to_vec()), - Some(_) => return Err(Error::UnknownAlpn), + Some(p) => return Err(Error::UnknownAlpn(p.to_string())), }; let mut stream = Stream::accept(&session, encoding).await?; diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index 4388dd1d6..5116502be 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -41,10 +41,7 @@ impl QuicheClient { let alpns = match url.scheme() { "https" => vec![web_transport_quiche::ALPN.as_bytes().to_vec()], - "moqt" | "moql" => moq_lite::alpns() - .into_iter() - .map(|alpn| alpn.as_bytes().to_vec()) - .collect(), + "moqt" | "moql" => moq_lite::alpns().iter().map(|alpn| alpn.as_bytes().to_vec()).collect(), _ => anyhow::bail!("url scheme must be 'https', 'moqt', or 'moql'"), }; diff --git a/rs/moq-native/src/quinn.rs b/rs/moq-native/src/quinn.rs index a38d9ecce..bcdc41f5e 100644 --- a/rs/moq-native/src/quinn.rs +++ b/rs/moq-native/src/quinn.rs @@ -81,10 +81,7 @@ impl QuinnClient { let alpns = match url.scheme() { "https" => vec![web_transport_quinn::ALPN.as_bytes().to_vec()], - "moqt" | "moql" => moq_lite::alpns() - .into_iter() - .map(|alpn| alpn.as_bytes().to_vec()) - .collect(), + "moqt" | "moql" => moq_lite::alpns().iter().map(|alpn| alpn.as_bytes().to_vec()).collect(), _ => anyhow::bail!("url scheme must be 'http', 'https', or 'moql'"), }; From b9393d72ed22c78cd0586e7346f8983f16baa718 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 11 Feb 2026 13:42:50 -0800 Subject: [PATCH 7/7] AI review. --- rs/moq-lite/src/client.rs | 2 +- rs/moq-native/src/iroh.rs | 12 ++++++------ rs/moq-native/src/quiche.rs | 13 +++++++------ rs/moq-native/src/quinn.rs | 7 ++----- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/rs/moq-lite/src/client.rs b/rs/moq-lite/src/client.rs index 20fef9fe3..744f44deb 100644 --- a/rs/moq-lite/src/client.rs +++ b/rs/moq-lite/src/client.rs @@ -102,7 +102,7 @@ impl Client { let request_id_max = ietf::RequestId( parameters .get_varint(ietf::ParameterVarInt::MaxRequestId) - .unwrap_or(u32::MAX as u64), + .unwrap_or_default(), ); let stream = stream.with_version(version); diff --git a/rs/moq-native/src/iroh.rs b/rs/moq-native/src/iroh.rs index 4ed648935..234770b87 100644 --- a/rs/moq-native/src/iroh.rs +++ b/rs/moq-native/src/iroh.rs @@ -66,12 +66,12 @@ impl IrohEndpointConfig { SecretKey::generate(&mut rand::rng()) }; - let mut builder = IrohEndpoint::builder().secret_key(secret_key).alpns(vec![ - web_transport_iroh::ALPN_H3.as_bytes().to_vec(), - moq_lite::lite::ALPN.as_bytes().to_vec(), - moq_lite::ietf::ALPN_14.as_bytes().to_vec(), - moq_lite::ietf::ALPN_15.as_bytes().to_vec(), - ]); + let mut alpns = vec![web_transport_iroh::ALPN_H3.as_bytes().to_vec()]; + for alpn in moq_lite::alpns() { + alpns.push(alpn.as_bytes().to_vec()); + } + + let mut builder = IrohEndpoint::builder().secret_key(secret_key).alpns(alpns); if let Some(addr) = self.bind_v4 { builder = builder.bind_addr_v4(addr); } diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index 5116502be..83d561018 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -78,11 +78,12 @@ impl QuicheClient { .connect(&host, port) .await .context("failed to connect to quiche server")?; - Ok(web_transport_quiche::Connection::raw( - conn, - request, - web_transport_quiche::proto::ConnectResponse::OK, - )) + + let alpn = conn.alpn().context("missing ALPN")?; + let alpn = std::str::from_utf8(&alpn).context("failed to decode ALPN")?; + + let response = web_transport_quiche::proto::ConnectResponse::OK.with_protocol(alpn); + Ok(web_transport_quiche::Connection::raw(conn, request, response)) } _ => unreachable!("unsupported URL scheme: {}", url.scheme()), } @@ -133,7 +134,7 @@ impl QuicheServer { fingerprints, })); - let mut alpns = vec![b"h3".to_vec(), moq_lite::lite::ALPN.as_bytes().to_vec()]; + let mut alpns = vec![b"h3".to_vec()]; for alpn in moq_lite::alpns() { alpns.push(alpn.as_bytes().to_vec()); } diff --git a/rs/moq-native/src/quinn.rs b/rs/moq-native/src/quinn.rs index bcdc41f5e..673ac41ee 100644 --- a/rs/moq-native/src/quinn.rs +++ b/rs/moq-native/src/quinn.rs @@ -82,7 +82,7 @@ impl QuinnClient { let alpns = match url.scheme() { "https" => vec![web_transport_quinn::ALPN.as_bytes().to_vec()], "moqt" | "moql" => moq_lite::alpns().iter().map(|alpn| alpn.as_bytes().to_vec()).collect(), - _ => anyhow::bail!("url scheme must be 'http', 'https', or 'moql'"), + _ => anyhow::bail!("url scheme must be 'https', 'moqt', or 'moql'"), }; config.alpn_protocols = alpns; @@ -209,10 +209,7 @@ impl QuinnServer { .with_no_client_auth() .with_cert_resolver(certs.clone()); - let mut alpns = vec![ - web_transport_quinn::ALPN.as_bytes().to_vec(), - moq_lite::lite::ALPN.as_bytes().to_vec(), - ]; + let mut alpns = vec![web_transport_quinn::ALPN.as_bytes().to_vec()]; for alpn in moq_lite::alpns() { alpns.push(alpn.as_bytes().to_vec()); }