//! Extensible component registry and event-processing contracts. //! //! A component kind supplies a versioned settings definition, a pure browser //! projection and optional durable handlers. The live provider and router know //! only these traits, so adding a gift wall or song-request component does not //! require branching on component kinds in the ingestion pipeline. use std::{ collections::{BTreeSet, HashMap}, error::Error, fmt, future::Future, pin::Pin, sync::{Arc, RwLock}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use crate::{ domain::{ComponentMessage, LiveEvent, LiveEventKind}, gift_effect::GiftEffectDefinition, gift_menu::{GiftMenuDefinition, GiftMenuProjection}, guard_effect::GuardEffectDefinition, overlay::OverlaySettings, song_request::{SongRequestDefinition, SongRequestProjection}, }; pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay"; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ComponentError { EmptyKind, AlreadyRegistered(String), NotRegistered(String), InvalidSettings { kind: String, detail: String, }, UnsupportedSettingsVersion { kind: String, found: u32, expected: u32, }, Projection(String), Handler(String), } impl fmt::Display for ComponentError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::EmptyKind => formatter.write_str("component kind cannot be empty"), Self::AlreadyRegistered(kind) => { write!(formatter, "component kind `{kind}` is already registered") } Self::NotRegistered(kind) => { write!(formatter, "component kind `{kind}` is not registered") } Self::InvalidSettings { kind, detail } => { write!( formatter, "invalid settings for component `{kind}`: {detail}" ) } Self::UnsupportedSettingsVersion { kind, found, expected, } => write!( formatter, "component `{kind}` settings version {found} is unsupported; expected {expected}" ), Self::Projection(detail) => write!(formatter, "component projection failed: {detail}"), Self::Handler(detail) => write!(formatter, "component event handler failed: {detail}"), } } } impl Error for ComponentError {} /// Persisted component instance. `owner_id` is trusted tenancy context and is /// omitted from public serialization. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ComponentInstance { pub id: Uuid, #[serde(skip_serializing)] pub owner_id: Uuid, /// Runtime identity of the owner's account-level source. This value is /// derived while loading the component and is not stored on its row. #[serde(rename = "sourceId")] pub account_source_id: Uuid, pub kind: String, pub name: String, pub enabled: bool, pub settings_version: u32, pub settings: Value, } impl ComponentInstance { pub fn new( owner_id: Uuid, account_source_id: Uuid, kind: impl Into, name: impl Into, settings_version: u32, settings: Value, ) -> Self { Self { id: Uuid::new_v4(), owner_id, account_source_id, kind: kind.into(), name: name.into(), enabled: true, settings_version, settings, } } } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct EventSubscription { kinds: BTreeSet, } impl EventSubscription { pub fn new(kinds: impl IntoIterator) -> Self { Self { kinds: kinds.into_iter().collect(), } } pub fn matches(&self, event: &LiveEvent) -> bool { self.kinds.contains(&event.kind()) } pub fn contains(&self, kind: LiveEventKind) -> bool { self.kinds.contains(&kind) } pub fn kinds(&self) -> impl Iterator + '_ { self.kinds.iter().copied() } } /// Static behavior and settings contract for one component kind. pub trait ComponentDefinition: Send + Sync { fn kind(&self) -> &'static str; fn settings_version(&self) -> u32; fn default_settings(&self) -> Value; fn validate_settings(&self, settings: Value) -> Result; fn subscriptions(&self, settings: &Value) -> Result; /// Override when a component changes its settings schema. Keeping migration /// here allows old instances to be upgraded without teaching the router /// about component-specific fields. fn migrate_settings( &self, from_version: u32, settings: Value, ) -> Result { if from_version == self.settings_version() { Ok(settings) } else { Err(ComponentError::UnsupportedSettingsVersion { kind: self.kind().to_owned(), found: from_version, expected: self.settings_version(), }) } } } /// A passive, side-effect-free transformation for a browser-facing component. /// It may filter or reshape an event, but must not write business data. pub trait EventProjection: Send + Sync { fn project( &self, component: &ComponentInstance, event: &LiveEvent, ) -> Result, ComponentError>; } /// Future returned by a durable business handler without requiring an /// `async-trait` dependency. pub type HandlerFuture<'a> = Pin> + Send + 'a>>; /// Future used by component-specific durable state snapshots during WebSocket /// authentication. Snapshot messages share the normal component envelope. pub type SnapshotFuture<'a> = Pin, ComponentError>> + Send + 'a>>; pub trait ComponentSnapshotProvider: Send + Sync { fn snapshot<'a>( &'a self, component: &'a ComponentInstance, room_id: &'a str, ) -> SnapshotFuture<'a>; } /// An active handler may perform durable side effects (for example recording a /// song request). It runs independently of WebSocket receiver count and should /// implement idempotency in its persistence layer. pub trait EventHandler: Send + Sync { fn name(&self) -> &'static str; fn accepts(&self, _component: &ComponentInstance, _event: &LiveEvent) -> bool { true } fn handle<'a>( &'a self, component: &'a ComponentInstance, event: Arc, ) -> HandlerFuture<'a>; } #[derive(Default)] pub struct PassthroughProjection; impl EventProjection for PassthroughProjection { fn project( &self, component: &ComponentInstance, event: &LiveEvent, ) -> Result, ComponentError> { ComponentMessage::from_live_event(component.id, event) .map(Some) .map_err(|error| ComponentError::Projection(error.to_string())) } } pub struct DanmakuOverlayDefinition; impl DanmakuOverlayDefinition { fn parse(&self, settings: Value) -> Result { serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings { kind: DANMAKU_OVERLAY_KIND.to_owned(), detail: error.to_string(), }) } } impl ComponentDefinition for DanmakuOverlayDefinition { fn kind(&self) -> &'static str { DANMAKU_OVERLAY_KIND } fn settings_version(&self) -> u32 { 1 } fn default_settings(&self) -> Value { serde_json::to_value(OverlaySettings::default()) .expect("OverlaySettings is always JSON serializable") } fn validate_settings(&self, settings: Value) -> Result { let settings = self.parse(settings)?.sanitize(); serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings { kind: DANMAKU_OVERLAY_KIND.to_owned(), detail: error.to_string(), }) } fn subscriptions(&self, settings: &Value) -> Result { let settings = self.parse(settings.clone())?; let mut kinds = Vec::with_capacity(9); if settings.show_danmaku { kinds.push(LiveEventKind::Danmaku); } if settings.show_enter { kinds.push(LiveEventKind::Enter); } if settings.show_gift { kinds.extend([LiveEventKind::Gift, LiveEventKind::GiftCombo]); } if settings.show_superchat { kinds.push(LiveEventKind::SuperChat); } if settings.show_guard { kinds.push(LiveEventKind::GuardPurchase); } if settings.show_like { kinds.push(LiveEventKind::Like); } if settings.show_share { kinds.push(LiveEventKind::Share); } Ok(EventSubscription::new(kinds)) } } /// Immutable routing snapshot returned by the registry. All contained trait /// objects are `Arc`, so routing never holds the registry lock across awaits. #[derive(Clone)] pub struct ComponentRuntime { definition: Arc, projection: Arc, handlers: Vec>, snapshot_provider: Option>, } impl ComponentRuntime { pub fn kind(&self) -> &'static str { self.definition.kind() } pub fn definition(&self) -> Arc { self.definition.clone() } pub fn validated_settings( &self, instance: &ComponentInstance, ) -> Result { let settings = self .definition .migrate_settings(instance.settings_version, instance.settings.clone())?; self.definition.validate_settings(settings) } pub fn subscriptions( &self, instance: &ComponentInstance, ) -> Result { let settings = self.validated_settings(instance)?; self.definition.subscriptions(&settings) } pub fn project( &self, instance: &ComponentInstance, event: &LiveEvent, ) -> Result, ComponentError> { self.projection.project(instance, event) } pub fn handlers(&self) -> Vec> { self.handlers.clone() } pub async fn snapshot( &self, component: &ComponentInstance, room_id: &str, ) -> Result, ComponentError> { match &self.snapshot_provider { Some(provider) => provider.snapshot(component, room_id).await, None => Ok(Vec::new()), } } } #[derive(Clone)] pub struct ComponentRegistry { entries: Arc>>, } impl ComponentRegistry { /// Create an empty registry for tests or applications that select their own /// component modules. pub fn new() -> Self { Self { entries: Arc::new(RwLock::new(HashMap::new())), } } /// Registry used by the current application. Future modules can be added by /// calling `register` during bootstrap. pub fn with_builtin_components() -> Self { let registry = Self::new(); registry .register( Arc::new(DanmakuOverlayDefinition), Arc::new(PassthroughProjection), ) .expect("built-in component kinds are unique"); registry .register( Arc::new(SongRequestDefinition), Arc::new(SongRequestProjection), ) .expect("built-in component kinds are unique"); registry .register( Arc::new(GiftEffectDefinition), Arc::new(PassthroughProjection), ) .expect("built-in component kinds are unique"); registry .register( Arc::new(GuardEffectDefinition), Arc::new(PassthroughProjection), ) .expect("built-in component kinds are unique"); registry .register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection)) .expect("built-in component kinds are unique"); registry } pub fn register( &self, definition: Arc, projection: Arc, ) -> Result<(), ComponentError> { let kind = definition.kind().trim(); if kind.is_empty() { return Err(ComponentError::EmptyKind); } let mut entries = self .entries .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); if entries.contains_key(kind) { return Err(ComponentError::AlreadyRegistered(kind.to_owned())); } entries.insert( kind.to_owned(), ComponentRuntime { definition, projection, handlers: Vec::new(), snapshot_provider: None, }, ); Ok(()) } pub fn register_handler( &self, kind: &str, handler: Arc, ) -> Result<(), ComponentError> { let mut entries = self .entries .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); let runtime = entries .get_mut(kind) .ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))?; runtime.handlers.push(handler); Ok(()) } pub fn register_snapshot_provider( &self, kind: &str, provider: Arc, ) -> Result<(), ComponentError> { let mut entries = self .entries .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); let runtime = entries .get_mut(kind) .ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))?; runtime.snapshot_provider = Some(provider); Ok(()) } pub fn runtime(&self, kind: &str) -> Result { self.entries .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .get(kind) .cloned() .ok_or_else(|| ComponentError::NotRegistered(kind.to_owned())) } pub fn validate_settings( &self, kind: &str, from_version: u32, settings: Value, ) -> Result { let runtime = self.runtime(kind)?; let settings = runtime .definition .migrate_settings(from_version, settings)?; runtime.definition.validate_settings(settings) } pub fn kinds(&self) -> Vec { let mut kinds: Vec<_> = self .entries .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .keys() .cloned() .collect(); kinds.sort(); kinds } } impl Default for ComponentRegistry { fn default() -> Self { Self::with_builtin_components() } } #[cfg(test)] mod tests { use super::*; #[test] fn builtin_overlay_settings_are_sanitized_and_define_subscriptions() { let registry = ComponentRegistry::default(); let mut settings = serde_json::to_value(OverlaySettings::default()).unwrap(); settings["maxVisible"] = Value::from(250); settings["showGift"] = Value::Bool(false); settings["showLike"] = Value::Bool(true); let validated = registry .validate_settings(DANMAKU_OVERLAY_KIND, 1, settings) .unwrap(); assert_eq!(validated["maxVisible"], 12); let instance = ComponentInstance::new( Uuid::new_v4(), Uuid::new_v4(), DANMAKU_OVERLAY_KIND, "弹幕姬", 1, validated, ); let subscriptions = registry .runtime(DANMAKU_OVERLAY_KIND) .unwrap() .subscriptions(&instance) .unwrap(); assert!(subscriptions.contains(LiveEventKind::Danmaku)); assert!(subscriptions.contains(LiveEventKind::Like)); assert!(!subscriptions.contains(LiveEventKind::Gift)); assert!(!subscriptions.contains(LiveEventKind::GiftCombo)); } #[test] fn builtin_song_request_is_registered_with_bounded_unlimited_defaults() { let registry = ComponentRegistry::default(); let runtime = registry.runtime("song_request").unwrap(); let mut settings = runtime.definition().default_settings(); settings["scrollSpeedPixelsPerSecond"] = Value::from(999); let validated = registry .validate_settings("song_request", 1, settings) .unwrap(); assert_eq!(validated["scrollSpeedPixelsPerSecond"], 200); assert_eq!(validated["maxQueueSize"], 0); assert_eq!(validated["maxRequestsPerViewer"], 0); assert_eq!(validated["requestCooldownSeconds"], 0); let instance = ComponentInstance::new( Uuid::new_v4(), Uuid::new_v4(), "song_request", "点歌姬", 1, validated, ); let subscriptions = runtime.subscriptions(&instance).unwrap(); assert!(subscriptions.contains(LiveEventKind::Danmaku)); assert!(!subscriptions.contains(LiveEventKind::Gift)); } #[test] fn builtin_gift_effect_only_subscribes_to_gifts() { let registry = ComponentRegistry::default(); assert!(registry.kinds().contains(&"gift_effect".to_owned())); let runtime = registry.runtime("gift_effect").unwrap(); let instance = ComponentInstance::new( Uuid::new_v4(), Uuid::new_v4(), "gift_effect", "礼物星雨", 1, runtime.definition().default_settings(), ); let subscriptions = runtime.subscriptions(&instance).unwrap(); assert!(subscriptions.contains(LiveEventKind::Gift)); assert!(!subscriptions.contains(LiveEventKind::GuardPurchase)); assert!(!subscriptions.contains(LiveEventKind::GiftCombo)); } #[test] fn builtin_guard_effect_only_subscribes_to_guard_purchases() { let registry = ComponentRegistry::default(); assert!(registry.kinds().contains(&"guard_effect".to_owned())); let runtime = registry.runtime("guard_effect").unwrap(); let instance = ComponentInstance::new( Uuid::new_v4(), Uuid::new_v4(), "guard_effect", "大航海特效", 1, runtime.definition().default_settings(), ); let subscriptions = runtime.subscriptions(&instance).unwrap(); assert!(subscriptions.contains(LiveEventKind::GuardPurchase)); assert!(!subscriptions.contains(LiveEventKind::Gift)); assert!(!subscriptions.contains(LiveEventKind::GiftCombo)); } #[test] fn builtin_gift_menu_is_registered_as_a_gift_and_guard_projection() { let registry = ComponentRegistry::default(); let runtime = registry.runtime("gift_menu").unwrap(); let instance = ComponentInstance::new( Uuid::new_v4(), Uuid::new_v4(), "gift_menu", "礼物菜单", 1, runtime.definition().default_settings(), ); let subscriptions = runtime.subscriptions(&instance).unwrap(); assert!(subscriptions.contains(LiveEventKind::Gift)); assert!(subscriptions.contains(LiveEventKind::GuardPurchase)); assert!(!subscriptions.contains(LiveEventKind::GiftCombo)); } #[test] fn duplicate_component_kinds_are_rejected() { let registry = ComponentRegistry::default(); let result = registry.register( Arc::new(DanmakuOverlayDefinition), Arc::new(PassthroughProjection), ); assert!(matches!(result, Err(ComponentError::AlreadyRegistered(_)))); } #[test] fn unsupported_settings_versions_fail_closed() { let registry = ComponentRegistry::default(); let result = registry.validate_settings( DANMAKU_OVERLAY_KIND, 99, serde_json::to_value(OverlaySettings::default()).unwrap(), ); assert!(matches!( result, Err(ComponentError::UnsupportedSettingsVersion { .. }) )); } }