Skip to main content

camel_component_ws/
lib.rs

1//! WebSocket component for rust-camel — Axum-based WebSocket server and Tokio-tungstenite client for bidirectional messaging.
2//!
3//! Main types: `WsComponent`, `WsBundle`, `WsConfig`, `WsServerConfig`, `WsClientConfig`, `WsEndpointConfig`.
4//! Main modules: `bundle`, `config`, `health`.
5
6pub mod bundle;
7pub mod config;
8pub mod health;
9
10pub use bundle::WsBundle;
11pub use config::{WsClientConfig, WsConfig, WsEndpointConfig, WsServerConfig};
12pub use health::WsHealthCheck;
13
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex, OnceLock};
16
17use async_trait::async_trait;
18use axum::body::Body;
19use axum::extract::ws::{CloseCode, CloseFrame, Message as WsMessage, WebSocket, WebSocketUpgrade};
20use axum::extract::{FromRequest, Request, State};
21use axum::http::{StatusCode, header};
22use axum::response::IntoResponse;
23use axum::{Router, serve};
24use camel_api::security_policy::AuthorizationDecision;
25use camel_component_api::{
26    Body as CamelBody, BoxProcessor, CamelError, Component, ConcurrencyModel, Consumer,
27    ConsumerContext, Endpoint, Exchange, ExchangeEnvelope, Message as CamelMessage,
28    NetworkRetryPolicy, ProducerContext, RuntimeObservability, retry_async,
29};
30use dashmap::DashMap;
31use futures::{SinkExt, StreamExt};
32use std::future::Future;
33use std::pin::Pin;
34use std::task::{Context, Poll};
35use tokio::sync::{OnceCell, RwLock, mpsc};
36use tokio::task::JoinHandle;
37use tokio_tungstenite::tungstenite;
38use tokio_tungstenite::tungstenite::client::IntoClientRequest;
39use tokio_tungstenite::tungstenite::protocol::Message as ClientWsMessage;
40use tower::Service;
41
42#[derive(Clone)]
43pub struct WsPathConfig {
44    pub max_connections: u32,
45    pub max_message_size: u32,
46    pub heartbeat_interval: std::time::Duration,
47    pub idle_timeout: std::time::Duration,
48    pub allow_origin: String,
49}
50
51impl Default for WsPathConfig {
52    fn default() -> Self {
53        let cfg = WsEndpointConfig::default();
54        Self {
55            max_connections: cfg.max_connections,
56            max_message_size: cfg.max_message_size,
57            heartbeat_interval: cfg.heartbeat_interval,
58            idle_timeout: cfg.idle_timeout,
59            allow_origin: cfg.allow_origin,
60        }
61    }
62}
63
64#[derive(Clone)]
65pub struct WsTlsConfig {
66    pub cert_path: String,
67    pub key_path: String,
68}
69
70type DispatchTable = Arc<RwLock<HashMap<String, mpsc::Sender<ExchangeEnvelope>>>>;
71
72struct ServerHandle {
73    state: WsAppState,
74    is_tls: bool,
75    _task: JoinHandle<()>,
76}
77
78struct ServerRegistryInner {
79    cell: Arc<OnceCell<ServerHandle>>,
80    ref_count: usize,
81}
82
83pub struct ServerRegistry {
84    inner: Mutex<HashMap<u16, ServerRegistryInner>>,
85}
86
87impl ServerRegistry {
88    pub fn global() -> &'static Self {
89        static REG: OnceLock<ServerRegistry> = OnceLock::new();
90        REG.get_or_init(|| Self {
91            inner: Mutex::new(HashMap::new()),
92        })
93    }
94
95    pub async fn get_or_spawn(
96        &'static self,
97        host: &str,
98        port: u16,
99        tls_config: Option<WsTlsConfig>,
100        runtime: Arc<dyn RuntimeObservability>,
101        route_id: String,
102    ) -> Result<WsAppState, CamelError> {
103        let wants_tls = tls_config.is_some();
104        let host_owned = host.to_string();
105
106        let (cell, _is_new) = {
107            let mut guard = self.inner.lock().map_err(|_| {
108                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
109            })?;
110            let entry = guard.entry(port).or_insert_with(|| ServerRegistryInner {
111                cell: Arc::new(OnceCell::new()),
112                ref_count: 0,
113            });
114            entry.ref_count += 1;
115            (entry.cell.clone(), entry.ref_count == 1)
116        };
117
118        let handle = cell
119            .get_or_try_init(|| async {
120                spawn_server(
121                    &host_owned,
122                    port,
123                    tls_config,
124                    runtime.clone(),
125                    route_id.clone(),
126                )
127                .await
128            })
129            .await?;
130
131        if wants_tls != handle.is_tls {
132            // Decrement ref count since we're rejecting this caller
133            let mut guard = self.inner.lock().map_err(|_| {
134                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
135            })?;
136            if let Some(entry) = guard.get_mut(&port) {
137                entry.ref_count -= 1;
138                if entry.ref_count == 0 {
139                    guard.remove(&port);
140                }
141            }
142            return Err(CamelError::EndpointCreationFailed(format!(
143                "Server on port {port} already running with different TLS mode"
144            )));
145        }
146
147        Ok(handle.state.clone())
148    }
149
150    /// Release a reference to the server on the given port.
151    /// When the last reference is released, the server task is aborted and the entry removed. (WS-005)
152    pub(crate) fn release(&self, port: u16) {
153        let mut guard = match self.inner.lock() {
154            Ok(g) => g,
155            Err(_) => return,
156        };
157        if let Some(entry) = guard.get_mut(&port) {
158            entry.ref_count = entry.ref_count.saturating_sub(1);
159            if entry.ref_count == 0 {
160                // Abort the server task if it exists (WS-001, WS-005)
161                if let Some(handle) = entry.cell.get() {
162                    handle._task.abort();
163                }
164                guard.remove(&port);
165                tracing::info!(port, "WebSocket server registry entry removed");
166            }
167        }
168    }
169}
170
171async fn spawn_server(
172    host: &str,
173    port: u16,
174    tls_config: Option<WsTlsConfig>,
175    runtime: Arc<dyn RuntimeObservability>,
176    route_id: String,
177) -> Result<ServerHandle, CamelError> {
178    let host_owned = host.to_string();
179    let addr = format!("{host}:{port}");
180    let dispatch: DispatchTable = Arc::new(RwLock::new(HashMap::new()));
181    let path_configs = Arc::new(DashMap::new());
182    let path_policies = Arc::new(DashMap::new());
183    let server_error = new_atomic_false();
184    let state = WsAppState {
185        dispatch: Arc::clone(&dispatch),
186        path_configs: Arc::clone(&path_configs),
187        path_policies: Arc::clone(&path_policies),
188        server_error: Arc::clone(&server_error),
189        runtime: Arc::clone(&runtime),
190        route_id: route_id.clone(),
191    };
192    let app = Router::new()
193        .fallback(dispatch_handler)
194        .with_state(state.clone());
195
196    let (task, is_tls) = if let Some(ref tls) = tls_config {
197        let rustls = load_tls_config(&tls.cert_path, &tls.key_path)?;
198        let parsed_addr = addr.parse().map_err(|e| {
199            CamelError::EndpointCreationFailed(format!("Invalid listen address {addr}: {e}"))
200        })?;
201        let tls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(rustls));
202        let error_flag = Arc::clone(&server_error);
203        let rt = Arc::clone(&runtime);
204        let rid = route_id.clone();
205        let task = tokio::spawn(async move {
206            if let Err(e) = axum_server::bind_rustls(parsed_addr, tls_cfg)
207                .serve(app.into_make_service())
208                .await
209            {
210                rt.health()
211                    .force_unhealthy_for_route(&rid, "g:ws:bind-tls", &e.to_string());
212                // log-policy: outside-contract
213                tracing::error!(
214                    host = host_owned,
215                    port = port,
216                    error = %e,
217                    "WebSocket server terminated with error"
218                );
219                error_flag.store(true, Ordering::Relaxed);
220            }
221        });
222        (task, true)
223    } else {
224        let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
225            CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
226        })?;
227        let error_flag = Arc::clone(&server_error);
228        let rt = Arc::clone(&runtime);
229        let rid = route_id.clone();
230        let task = tokio::spawn(async move {
231            if let Err(e) = serve(listener, app).await {
232                rt.health()
233                    .force_unhealthy_for_route(&rid, "g:ws:bind-plain", &e.to_string());
234                // log-policy: outside-contract
235                tracing::error!(
236                    host = host_owned,
237                    port = port,
238                    error = %e,
239                    "WebSocket server terminated with error"
240                );
241                error_flag.store(true, Ordering::Relaxed);
242            }
243        });
244        (task, false)
245    };
246
247    tracing::info!(host, port, is_tls, "WebSocket server started");
248
249    Ok(ServerHandle {
250        state,
251        is_tls,
252        _task: task,
253    })
254}
255
256#[derive(Clone)]
257pub struct WsAppState {
258    pub dispatch: DispatchTable,
259    pub path_configs: Arc<DashMap<String, WsPathConfig>>,
260    pub path_policies: Arc<DashMap<String, camel_component_api::SecurityContext>>,
261    pub server_error: Arc<AtomicBool>,
262    /// Observable runtime for ADR-0012 (e) metric and (g) health calls.
263    pub runtime: Arc<dyn RuntimeObservability>,
264    /// Route id of the consumer that created this server.
265    pub route_id: String,
266}
267
268pub struct WsConnectionRegistry {
269    connections: DashMap<String, mpsc::Sender<WsMessage>>,
270}
271
272static GLOBAL_CONNECTION_REGISTRIES: OnceLock<
273    DashMap<(String, u16, String), Arc<WsConnectionRegistry>>,
274> = OnceLock::new();
275
276fn global_registries() -> &'static DashMap<(String, u16, String), Arc<WsConnectionRegistry>> {
277    GLOBAL_CONNECTION_REGISTRIES.get_or_init(DashMap::new)
278}
279
280impl Default for WsConnectionRegistry {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286impl WsConnectionRegistry {
287    pub fn new() -> Self {
288        Self {
289            connections: DashMap::new(),
290        }
291    }
292
293    pub fn insert(&self, key: String, tx: mpsc::Sender<WsMessage>) {
294        self.connections.insert(key, tx);
295    }
296
297    pub fn remove(&self, key: &str) {
298        self.connections.remove(key);
299    }
300
301    pub fn len(&self) -> usize {
302        self.connections.len()
303    }
304
305    pub fn is_empty(&self) -> bool {
306        self.connections.is_empty()
307    }
308
309    pub fn snapshot_senders(&self) -> Vec<mpsc::Sender<WsMessage>> {
310        self.connections.iter().map(|e| e.value().clone()).collect()
311    }
312
313    pub fn get_senders_for_keys(&self, keys: &[String]) -> Vec<mpsc::Sender<WsMessage>> {
314        keys.iter()
315            .filter_map(|k| self.connections.get(k).map(|e| e.value().clone()))
316            .collect()
317    }
318}
319
320pub async fn dispatch_handler(
321    State(state): State<WsAppState>,
322    req: Request<Body>,
323) -> impl IntoResponse {
324    let path = req.uri().path().to_string();
325    let origin = req
326        .headers()
327        .get(header::ORIGIN)
328        .and_then(|value| value.to_str().ok())
329        .map(str::to_string);
330    let remote_addr = req
331        .extensions()
332        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
333        .map(|ci| ci.0.to_string())
334        .unwrap_or_default();
335    let table = state.dispatch.read().await;
336    if !table.contains_key(&path) {
337        return (
338            StatusCode::NOT_FOUND,
339            "no ws endpoint registered for this path",
340        )
341            .into_response();
342    }
343    drop(table);
344
345    let path_config = state
346        .path_configs
347        .get(&path)
348        .map(|entry| entry.value().clone())
349        .unwrap_or_default();
350    if !is_origin_allowed(&path_config.allow_origin, origin.as_deref()) {
351        return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
352    }
353
354    let mut principal_opt: Option<camel_api::security_policy::Principal> = None;
355    if let Some(sec_ctx) = state.path_policies.get(&path) {
356        let extracted =
357            camel_auth::extract_token_multi(req.headers(), req.uri(), &sec_ctx.credential_sources);
358
359        match extracted {
360            Some(extracted) => {
361                if matches!(
362                    extracted.source,
363                    camel_auth::CredentialSource::QueryParam { .. }
364                ) {
365                    let redacted =
366                        camel_auth::redact_query_params(req.uri(), &["access_token", "token"]);
367                    tracing::debug!(path = %redacted, "WS upgrade with query token (redacted)");
368                }
369                match sec_ctx
370                    .authenticator
371                    .authenticate_bearer(&extracted.token)
372                    .await
373                {
374                    Ok(principal) => {
375                        let mut exchange = camel_api::Exchange::new(camel_api::Message::new(
376                            camel_api::Body::Empty,
377                        ));
378                        camel_api::store_principal_properties(&mut exchange, &principal);
379                        match sec_ctx.policy.evaluate(&mut exchange).await {
380                            Ok(AuthorizationDecision::Granted { principal: _p }) => {
381                                tracing::debug!(path = %path, subject = %principal.subject, "WS upgrade authorized");
382                                principal_opt = Some(principal);
383                            }
384                            Ok(AuthorizationDecision::Denied { reason, .. }) => {
385                                tracing::warn!(path = %path, reason = %reason, "WS upgrade denied");
386                                return (StatusCode::FORBIDDEN, "Forbidden").into_response();
387                            }
388                            Err(e) => {
389                                state
390                                    .runtime
391                                    .metrics()
392                                    .increment_errors(&state.route_id, "e:ws:policy-eval");
393                                // log-policy: outside-contract
394                                tracing::error!(path = %path, error = %e, "Policy evaluation error during WS upgrade");
395                                return (
396                                    StatusCode::INTERNAL_SERVER_ERROR,
397                                    "Internal Server Error",
398                                )
399                                    .into_response();
400                            }
401                        }
402                    }
403                    Err(e) => {
404                        let (status, body) = match &e {
405                            camel_api::CamelError::Unauthenticated(_) => {
406                                (StatusCode::UNAUTHORIZED, "Unauthorized")
407                            }
408                            camel_api::CamelError::ProcessorError(msg)
409                                if msg.contains("auth provider unavailable") =>
410                            {
411                                (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable")
412                            }
413                            _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
414                        };
415                        tracing::warn!(path = %path, error = %e, "WS upgrade authentication failed");
416                        return (status, body).into_response();
417                    }
418                }
419            }
420            None => {
421                tracing::warn!(path = %path, "WS upgrade rejected: no credential found in any source");
422                return (
423                    StatusCode::UNAUTHORIZED,
424                    [("WWW-Authenticate", "Bearer".to_string())],
425                    "Unauthorized",
426                )
427                    .into_response();
428            }
429        }
430    }
431
432    let upgrade_headers: HashMap<String, String> = req
433        .headers()
434        .iter()
435        .filter_map(|(k, v)| Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string())))
436        .collect();
437
438    let ws: WebSocketUpgrade = match WebSocketUpgrade::from_request(req, &()).await {
439        Ok(ws) => ws,
440        Err(_) => {
441            return (StatusCode::BAD_REQUEST, "not a websocket request").into_response();
442        }
443    };
444
445    ws.on_upgrade(move |socket| {
446        ws_handler(
447            socket,
448            state,
449            path,
450            remote_addr,
451            upgrade_headers,
452            principal_opt,
453        )
454    })
455    .into_response()
456}
457
458#[allow(unused_variables)]
459async fn ws_handler(
460    socket: WebSocket,
461    state: WsAppState,
462    path: String,
463    remote_addr: String,
464    upgrade_headers: HashMap<String, String>,
465    principal: Option<camel_api::security_policy::Principal>,
466) {
467    let connection_key = uuid::Uuid::new_v4().to_string();
468    let path_config = state
469        .path_configs
470        .get(&path)
471        .map(|entry| entry.value().clone())
472        .unwrap_or_default();
473
474    let env_tx = {
475        let table = state.dispatch.read().await;
476        table.get(&path).cloned()
477    };
478    let Some(env_tx) = env_tx else {
479        return;
480    };
481
482    let (mut sink, mut stream) = socket.split();
483    let (out_tx, mut out_rx) = mpsc::channel::<WsMessage>(32);
484
485    let registry = global_registries();
486    let mut registry_key = None;
487    for entry in registry.iter() {
488        if entry.key().2 == path {
489            entry.value().insert(connection_key.clone(), out_tx.clone());
490            registry_key = Some(entry.key().clone());
491            break;
492        }
493    }
494
495    // Clone for writer closure and subsequent tracing (WS-009)
496    let conn_key_for_writer = connection_key.clone();
497    let path_for_writer = path.clone();
498
499    let writer = tokio::spawn(async move {
500        while let Some(msg) = out_rx.recv().await {
501            if let Err(e) = sink.send(msg).await {
502                tracing::warn!(
503                    connection_key = conn_key_for_writer,
504                    path = path_for_writer,
505                    error = %e,
506                    "WebSocket writer send error — closing connection"
507                );
508                break;
509            }
510        }
511    });
512
513    tracing::info!(
514        connection_key = connection_key,
515        path = path,
516        remote_addr = remote_addr,
517        "WebSocket connection opened"
518    );
519
520    let mut over_limit = false;
521    if let Some(key) = &registry_key
522        && let Some(entry) = registry.get(key)
523        && entry.len() > path_config.max_connections as usize
524    {
525        over_limit = true;
526    }
527    if over_limit {
528        try_send_with_backpressure(
529            &out_tx,
530            WsMessage::Close(Some(CloseFrame {
531                code: CloseCode::from(1013u16),
532                reason: "max connections exceeded".into(),
533            })),
534            "max-connections-close",
535        );
536        if let Some(key) = registry_key.clone()
537            && let Some(entry) = registry.get(&key)
538        {
539            entry.remove(&connection_key);
540        }
541        drop(out_tx);
542        let _ = writer.await;
543        return;
544    }
545
546    let heartbeat_task = if path_config.heartbeat_interval > std::time::Duration::ZERO {
547        let ping_tx = out_tx.clone();
548        let interval = path_config.heartbeat_interval;
549        Some(tokio::spawn(async move {
550            let mut ticker = tokio::time::interval(interval);
551            loop {
552                ticker.tick().await;
553                let _ = try_send_with_backpressure(
554                    &ping_tx,
555                    WsMessage::Ping(Vec::new().into()),
556                    "heartbeat-ping",
557                );
558            }
559        }))
560    } else {
561        None
562    };
563
564    loop {
565        let next_msg = if path_config.idle_timeout > std::time::Duration::ZERO {
566            match tokio::time::timeout(path_config.idle_timeout, stream.next()).await {
567                Ok(msg) => msg,
568                Err(_) => {
569                    try_send_with_backpressure(
570                        &out_tx,
571                        WsMessage::Close(Some(CloseFrame {
572                            code: CloseCode::from(1000u16),
573                            reason: "idle timeout".into(),
574                        })),
575                        "idle-timeout-close",
576                    );
577                    break;
578                }
579            }
580        } else {
581            stream.next().await
582        };
583
584        let Some(msg) = next_msg else {
585            break;
586        };
587
588        match msg {
589            Ok(WsMessage::Ping(data)) => {
590                tracing::debug!(
591                    connection_key = connection_key,
592                    path = path,
593                    "WebSocket ping received — sending pong"
594                );
595                let _ = try_send_with_backpressure(
596                    &out_tx,
597                    WsMessage::Pong(data),
598                    "ping-pong-response",
599                );
600            }
601            Ok(WsMessage::Pong(_)) => {
602                tracing::debug!(
603                    connection_key = connection_key,
604                    path = path,
605                    "WebSocket pong received"
606                );
607            }
608            Ok(WsMessage::Text(text)) => {
609                if text.len() > path_config.max_message_size as usize {
610                    try_send_with_backpressure(
611                        &out_tx,
612                        WsMessage::Close(Some(CloseFrame {
613                            code: CloseCode::from(1009u16),
614                            reason: "message too large".into(),
615                        })),
616                        "max-message-size-close-text",
617                    );
618                    break;
619                }
620
621                let mut message = CamelMessage::new(CamelBody::Text(text.to_string()));
622                message.set_header(
623                    "CamelWsConnectionKey",
624                    serde_json::Value::String(connection_key.clone()),
625                );
626                message.set_header("CamelWsPath", serde_json::Value::String(path.clone()));
627                message.set_header(
628                    "CamelWsRemoteAddress",
629                    serde_json::Value::String(remote_addr.clone()),
630                );
631
632                #[allow(unused_mut)]
633                let mut exchange = Exchange::new(message);
634                if let Some(ref p) = principal {
635                    camel_api::store_principal_properties(&mut exchange, p);
636                }
637                #[cfg(feature = "otel")]
638                {
639                    camel_otel::extract_into_exchange(&mut exchange, &upgrade_headers);
640                }
641                if env_tx
642                    .send(ExchangeEnvelope {
643                        exchange,
644                        reply_tx: None,
645                    })
646                    .await
647                    .is_err()
648                {
649                    break;
650                }
651            }
652            Ok(WsMessage::Binary(data)) => {
653                if data.len() > path_config.max_message_size as usize {
654                    try_send_with_backpressure(
655                        &out_tx,
656                        WsMessage::Close(Some(CloseFrame {
657                            code: CloseCode::from(1009u16),
658                            reason: "message too large".into(),
659                        })),
660                        "max-message-size-close-binary",
661                    );
662                    break;
663                }
664
665                let mut message = CamelMessage::new(CamelBody::Bytes(data));
666                message.set_header(
667                    "CamelWsConnectionKey",
668                    serde_json::Value::String(connection_key.clone()),
669                );
670                message.set_header("CamelWsPath", serde_json::Value::String(path.clone()));
671                message.set_header(
672                    "CamelWsRemoteAddress",
673                    serde_json::Value::String(remote_addr.clone()),
674                );
675
676                #[allow(unused_mut)]
677                let mut exchange = Exchange::new(message);
678                if let Some(ref p) = principal {
679                    camel_api::store_principal_properties(&mut exchange, p);
680                }
681                #[cfg(feature = "otel")]
682                {
683                    camel_otel::extract_into_exchange(&mut exchange, &upgrade_headers);
684                }
685                if env_tx
686                    .send(ExchangeEnvelope {
687                        exchange,
688                        reply_tx: None,
689                    })
690                    .await
691                    .is_err()
692                {
693                    break;
694                }
695            }
696            Ok(WsMessage::Close(cf)) => {
697                let reason = cf
698                    .as_ref()
699                    .map(|f| f.reason.to_string())
700                    .unwrap_or_default();
701                tracing::info!(
702                    connection_key = connection_key,
703                    path = path,
704                    reason = reason,
705                    "WebSocket connection closed by peer"
706                );
707                break;
708            }
709            Err(e) => {
710                tracing::warn!(
711                    connection_key = connection_key,
712                    path = path,
713                    error = %e,
714                    "WebSocket receive error"
715                );
716                break;
717            }
718        }
719    }
720
721    if let Some(task) = heartbeat_task {
722        task.abort();
723    }
724
725    if let Some(key) = registry_key
726        && let Some(entry) = registry.get(&key)
727    {
728        entry.remove(&connection_key);
729    }
730    drop(out_tx);
731    let _ = writer.await;
732
733    tracing::info!(
734        connection_key = connection_key,
735        path = path,
736        "WebSocket connection closed"
737    );
738}
739
740pub struct WsComponent {
741    pub(crate) config: WsConfig,
742}
743
744impl WsComponent {
745    pub fn new() -> Self {
746        Self {
747            config: WsConfig::default(),
748        }
749    }
750
751    pub fn with_config(config: WsConfig) -> Self {
752        Self { config }
753    }
754}
755
756impl Default for WsComponent {
757    fn default() -> Self {
758        Self::new()
759    }
760}
761
762impl Component for WsComponent {
763    fn scheme(&self) -> &str {
764        "ws"
765    }
766
767    fn create_endpoint(
768        &self,
769        uri: &str,
770        ctx: &dyn camel_component_api::ComponentContext,
771    ) -> Result<Box<dyn Endpoint>, CamelError> {
772        self.config.validate()?;
773        let mut cfg = WsEndpointConfig::from_uri(uri)?;
774        if let Some(v) = self.config.max_connections {
775            cfg.max_connections = v;
776        }
777        if let Some(v) = self.config.max_message_size {
778            cfg.max_message_size = v;
779        }
780        if let Some(v) = self.config.heartbeat_interval_ms {
781            cfg.heartbeat_interval = std::time::Duration::from_millis(v);
782        }
783        if let Some(v) = self.config.idle_timeout_ms {
784            cfg.idle_timeout = std::time::Duration::from_millis(v);
785        }
786        if let Some(v) = self.config.connect_timeout_ms {
787            cfg.connect_timeout = std::time::Duration::from_millis(v);
788        }
789        if let Some(v) = self.config.response_timeout_ms {
790            cfg.response_timeout = std::time::Duration::from_millis(v);
791        }
792        if let Some(v) = self.config.send_timeout_ms {
793            cfg.send_timeout = std::time::Duration::from_millis(v);
794        }
795        if let Some(v) = self.config.binary_payload {
796            cfg.binary_payload = v;
797        }
798        if let Some(ref v) = self.config.subprotocols {
799            cfg.subprotocols = v.clone();
800        }
801        let health_check = WsHealthCheck::new(cfg.host.clone(), cfg.port);
802        ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
803        Ok(Box::new(WsEndpoint {
804            uri: uri.to_string(),
805            cfg,
806        }))
807    }
808}
809
810pub struct WssComponent {
811    pub(crate) config: WsConfig,
812}
813
814impl WssComponent {
815    pub fn new() -> Self {
816        Self {
817            config: WsConfig::default(),
818        }
819    }
820
821    pub fn with_config(config: WsConfig) -> Self {
822        Self { config }
823    }
824}
825
826impl Default for WssComponent {
827    fn default() -> Self {
828        Self::new()
829    }
830}
831
832impl Component for WssComponent {
833    fn scheme(&self) -> &str {
834        "wss"
835    }
836
837    fn create_endpoint(
838        &self,
839        uri: &str,
840        ctx: &dyn camel_component_api::ComponentContext,
841    ) -> Result<Box<dyn Endpoint>, CamelError> {
842        self.config.validate()?;
843        let mut cfg = WsEndpointConfig::from_uri(uri)?;
844        if let Some(v) = self.config.max_connections {
845            cfg.max_connections = v;
846        }
847        if let Some(v) = self.config.max_message_size {
848            cfg.max_message_size = v;
849        }
850        if let Some(v) = self.config.heartbeat_interval_ms {
851            cfg.heartbeat_interval = std::time::Duration::from_millis(v);
852        }
853        if let Some(v) = self.config.idle_timeout_ms {
854            cfg.idle_timeout = std::time::Duration::from_millis(v);
855        }
856        if let Some(v) = self.config.connect_timeout_ms {
857            cfg.connect_timeout = std::time::Duration::from_millis(v);
858        }
859        if let Some(v) = self.config.response_timeout_ms {
860            cfg.response_timeout = std::time::Duration::from_millis(v);
861        }
862        if let Some(v) = self.config.send_timeout_ms {
863            cfg.send_timeout = std::time::Duration::from_millis(v);
864        }
865        if let Some(v) = self.config.binary_payload {
866            cfg.binary_payload = v;
867        }
868        if let Some(ref v) = self.config.subprotocols {
869            cfg.subprotocols = v.clone();
870        }
871        let health_check = WsHealthCheck::new(cfg.host.clone(), cfg.port);
872        ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
873        Ok(Box::new(WsEndpoint {
874            uri: uri.to_string(),
875            cfg,
876        }))
877    }
878}
879
880struct WsEndpoint {
881    uri: String,
882    cfg: WsEndpointConfig,
883}
884
885impl Endpoint for WsEndpoint {
886    fn uri(&self) -> &str {
887        &self.uri
888    }
889
890    fn create_consumer(
891        &self,
892        rt: Arc<dyn camel_component_api::RuntimeObservability>,
893    ) -> Result<Box<dyn Consumer>, CamelError> {
894        Ok(Box::new(WsConsumer::new(self.cfg.server_config(), rt)))
895    }
896
897    fn create_producer(
898        &self,
899        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
900        _ctx: &ProducerContext,
901    ) -> Result<BoxProcessor, CamelError> {
902        Ok(BoxProcessor::new(WsProducer::new(self.cfg.client_config())))
903    }
904}
905
906pub struct WsConsumer {
907    cfg: WsServerConfig,
908    registry: Arc<WsConnectionRegistry>,
909    server_state: Option<WsAppState>,
910    registry_key: Option<(String, u16, String)>,
911    forward_task: Option<JoinHandle<Result<(), CamelError>>>,
912    security_ctx: Option<camel_component_api::SecurityContext>,
913    /// Runtime observability handle for ADR-0012 metrics and health calls.
914    runtime: Arc<dyn camel_component_api::RuntimeObservability>,
915}
916
917impl WsConsumer {
918    pub fn new(
919        cfg: WsServerConfig,
920        runtime: Arc<dyn camel_component_api::RuntimeObservability>,
921    ) -> Self {
922        Self {
923            cfg,
924            registry: Arc::new(WsConnectionRegistry::new()),
925            server_state: None,
926            registry_key: None,
927            forward_task: None,
928            security_ctx: None,
929            runtime,
930        }
931    }
932}
933
934#[async_trait]
935impl Consumer for WsConsumer {
936    async fn start(&mut self, ctx: ConsumerContext) -> Result<(), CamelError> {
937        // Reject double-start (WS-006)
938        if self.server_state.is_some() {
939            return Err(CamelError::EndpointCreationFailed(
940                "WebSocket consumer already started".into(),
941            ));
942        }
943
944        tracing::info!(
945            host = self.cfg.inner.host,
946            port = self.cfg.inner.port,
947            path = self.cfg.inner.path,
948            scheme = self.cfg.inner.scheme,
949            "WebSocket consumer starting"
950        );
951
952        let tls_config = if self.cfg.inner.scheme == "wss" {
953            let cert_path = self.cfg.inner.tls_cert.clone().ok_or_else(|| {
954                CamelError::EndpointCreationFailed("TLS cert path is required for wss".into())
955            })?;
956            let key_path = self.cfg.inner.tls_key.clone().ok_or_else(|| {
957                CamelError::EndpointCreationFailed("TLS key path is required for wss".into())
958            })?;
959            Some(WsTlsConfig {
960                cert_path,
961                key_path,
962            })
963        } else {
964            None
965        };
966
967        let state = ServerRegistry::global()
968            .get_or_spawn(
969                &self.cfg.inner.host,
970                self.cfg.inner.port,
971                tls_config,
972                self.runtime.clone(),
973                ctx.route_id().to_string(),
974            )
975            .await?;
976
977        let (env_tx, mut env_rx) = mpsc::channel::<ExchangeEnvelope>(64);
978        {
979            let mut table = state.dispatch.write().await;
980            table.insert(self.cfg.inner.path.clone(), env_tx);
981        }
982
983        state.path_configs.insert(
984            self.cfg.inner.path.clone(),
985            WsPathConfig {
986                max_connections: self.cfg.inner.max_connections,
987                max_message_size: self.cfg.inner.max_message_size,
988                heartbeat_interval: self.cfg.inner.heartbeat_interval,
989                idle_timeout: self.cfg.inner.idle_timeout,
990                allow_origin: self.cfg.inner.allow_origin.clone(),
991            },
992        );
993
994        if let Some(ref sec_ctx) = self.security_ctx {
995            let path = self.cfg.inner.path.clone();
996            state.path_policies.insert(path, sec_ctx.clone());
997        }
998
999        let registry_key = (
1000            self.cfg.inner.canonical_host(),
1001            self.cfg.inner.port,
1002            self.cfg.inner.path.clone(),
1003        );
1004        global_registries().insert(registry_key.clone(), Arc::clone(&self.registry));
1005
1006        let sender = ctx.sender();
1007        let forward_task: JoinHandle<Result<(), CamelError>> = tokio::spawn(async move {
1008            while let Some(envelope) = env_rx.recv().await {
1009                if sender.send(envelope).await.is_err() {
1010                    break;
1011                }
1012            }
1013            Ok(())
1014        });
1015
1016        self.server_state = Some(state);
1017        self.registry_key = Some(registry_key);
1018        self.forward_task = Some(forward_task);
1019        Ok(())
1020    }
1021
1022    async fn stop(&mut self) -> Result<(), CamelError> {
1023        tracing::info!(
1024            host = self.cfg.inner.host,
1025            port = self.cfg.inner.port,
1026            path = self.cfg.inner.path,
1027            "WebSocket consumer stopping"
1028        );
1029
1030        let close_msg = WsMessage::Close(Some(axum::extract::ws::CloseFrame {
1031            code: axum::extract::ws::CloseCode::from(1001u16),
1032            reason: "consumer stopping".into(),
1033        }));
1034        for tx in self.registry.snapshot_senders() {
1035            let _ = try_send_with_backpressure(&tx, close_msg.clone(), "consumer-stop-close");
1036        }
1037
1038        let mut had_server_error = false;
1039
1040        if let Some(state) = self.server_state.take() {
1041            had_server_error = state.server_error.load(Ordering::Relaxed);
1042            state.path_policies.remove(&self.cfg.inner.path);
1043            let mut table = state.dispatch.write().await;
1044            table.remove(&self.cfg.inner.path);
1045            state.path_configs.remove(&self.cfg.inner.path);
1046        }
1047
1048        if let Some(key) = self.registry_key.take() {
1049            global_registries().remove(&key);
1050            ServerRegistry::global().release(key.1);
1051        }
1052
1053        if let Some(task) = self.forward_task.take() {
1054            task.abort();
1055        }
1056
1057        tracing::info!(
1058            host = self.cfg.inner.host,
1059            port = self.cfg.inner.port,
1060            path = self.cfg.inner.path,
1061            "WebSocket consumer stopped"
1062        );
1063
1064        if had_server_error {
1065            tracing::warn!(
1066                host = self.cfg.inner.host,
1067                port = self.cfg.inner.port,
1068                path = self.cfg.inner.path,
1069                "WebSocket server had errors during its lifetime"
1070            );
1071            return Err(CamelError::ProcessorError(
1072                "WebSocket server terminated with errors during its lifetime".into(),
1073            ));
1074        }
1075
1076        Ok(())
1077    }
1078
1079    fn concurrency_model(&self) -> ConcurrencyModel {
1080        ConcurrencyModel::Concurrent {
1081            max: Some(self.cfg.inner.max_connections as usize),
1082        }
1083    }
1084
1085    fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
1086        self.forward_task.take()
1087    }
1088
1089    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1090        self.security_ctx = Some(ctx);
1091    }
1092}
1093
1094use std::sync::atomic::{AtomicBool, Ordering};
1095
1096fn new_atomic_false() -> Arc<AtomicBool> {
1097    Arc::new(AtomicBool::new(false))
1098}
1099
1100/// Classify a WebSocket error as retryable (transient network failure).
1101///
1102/// Retryable: connection refused, timeout, connection failed.
1103/// Permanent: anything else (protocol errors, auth failures, etc.).
1104#[inline]
1105fn is_retryable_ws_error(err: &CamelError) -> bool {
1106    let s = err.to_string();
1107    s.contains("connection refused") || s.contains("timeout") || s.contains("connection failed")
1108}
1109
1110#[derive(Clone)]
1111pub struct WsProducer {
1112    cfg: WsClientConfig,
1113    /// Shared flag set by the async future when server-send hits backpressure,
1114    /// so that the next `poll_ready` call can return an error. (WS-003)
1115    backpressure_flag: Arc<AtomicBool>,
1116}
1117
1118impl WsProducer {
1119    pub fn new(cfg: WsClientConfig) -> Self {
1120        Self {
1121            cfg,
1122            backpressure_flag: Arc::new(AtomicBool::new(false)),
1123        }
1124    }
1125}
1126
1127impl Service<Exchange> for WsProducer {
1128    type Response = Exchange;
1129    type Error = CamelError;
1130    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1131
1132    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
1133        // Return error if last server-send hit backpressure (WS-003)
1134        if self.backpressure_flag.swap(false, Ordering::Relaxed) {
1135            return Poll::Ready(Err(CamelError::ProcessorError(
1136                "WebSocket producer backpressure: previous send was dropped due to full channel"
1137                    .into(),
1138            )));
1139        }
1140        Poll::Ready(Ok(()))
1141    }
1142
1143    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1144        let cfg = self.cfg.clone();
1145        let backpressure_flag = Arc::clone(&self.backpressure_flag);
1146
1147        Box::pin(async move {
1148            let canonical_host = cfg.inner.canonical_host();
1149            let key = (
1150                canonical_host.clone(),
1151                cfg.inner.port,
1152                cfg.inner.path.clone(),
1153            );
1154
1155            let send_to_all = exchange
1156                .input
1157                .header("CamelWsSendToAll")
1158                .and_then(|v| v.as_bool())
1159                .or_else(|| exchange.input.header("sendToAll").and_then(|v| v.as_bool()))
1160                .unwrap_or(false);
1161
1162            let conn_keys_header = exchange
1163                .input
1164                .header("CamelWsConnectionKey")
1165                .and_then(|v| v.as_str())
1166                .map(str::to_string);
1167
1168            let local_exists = global_registries().contains_key(&key);
1169            let server_send_mode = send_to_all || conn_keys_header.is_some() || local_exists;
1170
1171            let message_type = exchange
1172                .input
1173                .header("CamelWsMessageType")
1174                .and_then(|v| v.as_str())
1175                .unwrap_or("text")
1176                .to_ascii_lowercase();
1177
1178            if server_send_mode {
1179                let registry = global_registries().get(&key).map(|e| Arc::clone(e.value()));
1180                let Some(registry) = registry else {
1181                    return Err(CamelError::ProcessorError(format!(
1182                        "WebSocket local consumer not found for {}:{}{}",
1183                        canonical_host, cfg.inner.port, cfg.inner.path
1184                    )));
1185                };
1186
1187                let out_msg = body_to_axum_ws_message(
1188                    std::mem::take(&mut exchange.input.body),
1189                    &message_type,
1190                )
1191                .await?;
1192
1193                let targets = if send_to_all {
1194                    registry.snapshot_senders()
1195                } else if let Some(keys) = conn_keys_header {
1196                    let parsed: Vec<String> = keys
1197                        .split(',')
1198                        .map(str::trim)
1199                        .filter(|k| !k.is_empty())
1200                        .map(|k| k.to_string())
1201                        .collect();
1202                    registry.get_senders_for_keys(&parsed)
1203                } else {
1204                    registry.snapshot_senders()
1205                };
1206
1207                let mut dropped = 0usize;
1208                for tx in &targets {
1209                    if !try_send_with_backpressure(tx, out_msg.clone(), "producer-send") {
1210                        dropped += 1;
1211                    }
1212                }
1213
1214                if dropped > 0 {
1215                    tracing::warn!(
1216                        host = canonical_host,
1217                        port = cfg.inner.port,
1218                        path = cfg.inner.path,
1219                        dropped,
1220                        total = targets.len(),
1221                        "WebSocket producer dropped messages due to backpressure"
1222                    );
1223                    exchange.input.set_header(
1224                        "CamelWsDeliveryDropped",
1225                        serde_json::Value::Number(dropped.into()),
1226                    );
1227                    // Signal backpressure for next poll_ready call (WS-003)
1228                    backpressure_flag.store(true, Ordering::Relaxed);
1229                    if dropped == targets.len() {
1230                        return Err(CamelError::ProcessorError(format!(
1231                            "WebSocket producer: all {dropped} message(s) dropped due to backpressure"
1232                        )));
1233                    }
1234                }
1235
1236                tracing::debug!(
1237                    host = canonical_host,
1238                    port = cfg.inner.port,
1239                    path = cfg.inner.path,
1240                    targets = targets.len(),
1241                    "WebSocket producer server-send complete"
1242                );
1243
1244                return Ok(exchange);
1245            }
1246
1247            let url = format!(
1248                "{}://{}:{}{}",
1249                cfg.inner.scheme, cfg.inner.host, cfg.inner.port, cfg.inner.path
1250            );
1251
1252            tracing::debug!(url = url, "WebSocket producer connecting");
1253
1254            #[allow(unused_mut)]
1255            let mut request = url
1256                .clone()
1257                .into_client_request()
1258                .map_err(|e| CamelError::ProcessorError(format!("WebSocket request error: {e}")))?;
1259
1260            #[cfg(feature = "otel")]
1261            {
1262                let mut otel_headers = HashMap::new();
1263                camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
1264                for (k, v) in otel_headers {
1265                    if let (Ok(name), Ok(val)) = (
1266                        http::header::HeaderName::from_bytes(k.as_bytes()),
1267                        http::header::HeaderValue::from_str(&v),
1268                    ) {
1269                        request.headers_mut().insert(name, val);
1270                    }
1271                }
1272            }
1273
1274            // Add Sec-WebSocket-Protocol header if subprotocols configured (WS-007)
1275            if !cfg.inner.subprotocols.is_empty() {
1276                let proto_value = cfg.inner.subprotocols.join(", ");
1277                if let (Ok(name), Ok(val)) = (
1278                    http::header::HeaderName::from_bytes(b"Sec-WebSocket-Protocol"),
1279                    http::header::HeaderValue::from_str(&proto_value),
1280                ) {
1281                    request.headers_mut().insert(name, val);
1282                }
1283            }
1284
1285            // Determine message type: respect binary_payload config (WS-018)
1286            let effective_message_type = if cfg.inner.binary_payload {
1287                "binary"
1288            } else {
1289                &message_type
1290            };
1291
1292            let reconnect_policy = cfg.inner.reconnect_policy.clone();
1293            let mut ws_stream =
1294                connect_ws_with_retry(request, &url, cfg.inner.connect_timeout, &reconnect_policy)
1295                    .await?;
1296
1297            // Close/reconnect path: rate-limited bail. On close frame, sleep
1298            // delay_for(0) and return Err to signal the outer route to re-invoke
1299            // the producer. The attempts counter below bounds how many times
1300            // we'll signal reconnect before terminating. Independent counter —
1301            // OLD code shared a counter with the connect loop above; this is a
1302            // behavior change (cleaner separation of concerns).
1303            let attempts = 0u32;
1304
1305            let out_msg = body_to_client_ws_message(
1306                std::mem::take(&mut exchange.input.body),
1307                effective_message_type,
1308            )
1309            .await?;
1310
1311            ws_stream
1312                .send(out_msg)
1313                .await
1314                .map_err(|e| CamelError::ProcessorError(format!("WebSocket send failed: {e}")))?;
1315
1316            let incoming = tokio::time::timeout(cfg.inner.response_timeout, async {
1317                loop {
1318                    match ws_stream.next().await {
1319                        Some(Ok(ClientWsMessage::Ping(_))) | Some(Ok(ClientWsMessage::Pong(_))) => {
1320                            continue;
1321                        }
1322                        other => break other,
1323                    }
1324                }
1325            })
1326            .await
1327            .map_err(|_| CamelError::ProcessorError("WebSocket response timeout".into()))?;
1328
1329            match incoming {
1330                Some(Ok(ClientWsMessage::Text(text))) => {
1331                    tracing::debug!(url = url, "WebSocket producer received text response");
1332                    exchange.input.body = CamelBody::Text(text.to_string());
1333                }
1334                Some(Ok(ClientWsMessage::Binary(data))) => {
1335                    tracing::debug!(url = url, "WebSocket producer received binary response");
1336                    exchange.input.body = CamelBody::Bytes(data);
1337                }
1338                Some(Ok(ClientWsMessage::Close(frame))) => {
1339                    let normal = frame
1340                        .as_ref()
1341                        .map(|f| {
1342                            f.code == tungstenite::protocol::frame::coding::CloseCode::Normal
1343                                || f.code == tungstenite::protocol::frame::coding::CloseCode::Away
1344                        })
1345                        .unwrap_or(true);
1346
1347                    if normal {
1348                        tracing::debug!(url = url, "WebSocket producer received normal close");
1349                        exchange.input.body = CamelBody::Empty;
1350                    } else if reconnect_policy.should_retry(attempts + 1) {
1351                        let delay = reconnect_policy.delay_for(0); // fresh delay on close
1352                        tracing::warn!(
1353                            url = url,
1354                            attempt = attempts + 1,
1355                            delay_ms = delay.as_millis(),
1356                            "WebSocket closed by peer — reconnecting"
1357                        );
1358                        tokio::time::sleep(delay).await;
1359                        return Err(CamelError::ProcessorError(format!(
1360                            "WebSocket reconnect required after close: code {}",
1361                            frame.map(|f| u16::from(f.code)).unwrap_or_default()
1362                        )));
1363                    } else {
1364                        let code = frame.map(|f| u16::from(f.code)).unwrap_or_default();
1365                        return Err(CamelError::ProcessorError(format!(
1366                            "WebSocket peer closed: code {code}"
1367                        )));
1368                    }
1369                }
1370                Some(Ok(_)) | None => {
1371                    exchange.input.body = CamelBody::Empty;
1372                }
1373                Some(Err(e)) => {
1374                    return Err(CamelError::ProcessorError(format!(
1375                        "WebSocket receive failed: {e}"
1376                    )));
1377                }
1378            }
1379
1380            let _ = ws_stream.close(None).await;
1381            tracing::debug!(url = url, "WebSocket producer connection closed");
1382            Ok(exchange)
1383        })
1384    }
1385}
1386
1387async fn body_to_axum_ws_message(
1388    body: CamelBody,
1389    message_type: &str,
1390) -> Result<WsMessage, CamelError> {
1391    match message_type {
1392        "binary" => Ok(WsMessage::Binary(body.into_bytes(10 * 1024 * 1024).await?)),
1393        _ => Ok(WsMessage::Text(body_to_text(body).await?.into())),
1394    }
1395}
1396
1397async fn body_to_client_ws_message(
1398    body: CamelBody,
1399    message_type: &str,
1400) -> Result<ClientWsMessage, CamelError> {
1401    match message_type {
1402        "binary" => Ok(ClientWsMessage::Binary(
1403            body.into_bytes(10 * 1024 * 1024).await?,
1404        )),
1405        _ => Ok(ClientWsMessage::Text(body_to_text(body).await?.into())),
1406    }
1407}
1408
1409async fn body_to_text(body: CamelBody) -> Result<String, CamelError> {
1410    Ok(match body {
1411        CamelBody::Empty => String::new(),
1412        CamelBody::Text(s) => s,
1413        CamelBody::Xml(s) => s,
1414        CamelBody::Json(v) => v.to_string(),
1415        CamelBody::Bytes(b) => String::from_utf8_lossy(&b).to_string(),
1416        CamelBody::Stream(stream) => {
1417            let bytes = CamelBody::Stream(stream)
1418                .into_bytes(10 * 1024 * 1024)
1419                .await?;
1420            String::from_utf8_lossy(&bytes).to_string()
1421        }
1422    })
1423}
1424
1425fn is_origin_allowed(allowed_origin: &str, request_origin: Option<&str>) -> bool {
1426    if allowed_origin == "*" {
1427        return true;
1428    }
1429    request_origin.is_some_and(|origin| origin == allowed_origin)
1430}
1431
1432fn try_send_with_backpressure(tx: &mpsc::Sender<WsMessage>, msg: WsMessage, context: &str) -> bool {
1433    match tx.try_send(msg) {
1434        Ok(()) => true,
1435        Err(error) => {
1436            tracing::warn!(%context, %error, "dropping websocket outbound message due to backpressure");
1437            false
1438        }
1439    }
1440}
1441
1442fn load_tls_config(
1443    cert_path: &str,
1444    key_path: &str,
1445) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1446    use std::fs::File;
1447    use std::io::BufReader;
1448
1449    let cert_file = File::open(cert_path)
1450        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1451    let key_file = File::open(key_path)
1452        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1453
1454    let certs = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1455        .collect::<Result<Vec<_>, _>>()
1456        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1457
1458    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1459        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1460        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1461
1462    tokio_rustls::rustls::ServerConfig::builder()
1463        .with_no_client_auth()
1464        .with_single_cert(certs, key)
1465        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1466}
1467
1468fn map_connect_error(err: tungstenite::Error, url: &str) -> CamelError {
1469    match err {
1470        tungstenite::Error::Io(ioe) if ioe.kind() == std::io::ErrorKind::ConnectionRefused => {
1471            CamelError::ProcessorError(format!("WebSocket connection refused: {ioe}"))
1472        }
1473        tungstenite::Error::Tls(_) => {
1474            CamelError::ProcessorError("WebSocket TLS handshake failed: handshake error".into())
1475        }
1476        other => {
1477            let msg = other.to_string();
1478            if msg.to_lowercase().contains("connection refused") {
1479                CamelError::ProcessorError(format!("WebSocket connection refused: {msg}"))
1480            } else if msg.to_lowercase().contains("tls") {
1481                CamelError::ProcessorError(format!("WebSocket TLS handshake failed: {msg}"))
1482            } else {
1483                CamelError::ProcessorError(format!("WebSocket connection failed ({url}): {msg}"))
1484            }
1485        }
1486    }
1487}
1488
1489/// Connect to a WebSocket server with retry logic using the configured
1490/// [`NetworkRetryPolicy`]. Extracted for testability so the regression test
1491/// (rc-1nm) can drive the real production connect path rather than a
1492/// synthetic fake.
1493async fn connect_ws_with_retry<R>(
1494    request: R,
1495    url: &str,
1496    connect_timeout: std::time::Duration,
1497    reconnect_policy: &NetworkRetryPolicy,
1498) -> Result<
1499    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
1500    CamelError,
1501>
1502where
1503    R: IntoClientRequest + Unpin + Clone,
1504{
1505    let url_owned = url.to_string();
1506    retry_async(
1507        reconnect_policy,
1508        Some("ws-producer"),
1509        || {
1510            let r = request.clone();
1511            let url = url_owned.clone();
1512            async move {
1513                match tokio::time::timeout(connect_timeout, tokio_tungstenite::connect_async(r))
1514                    .await
1515                {
1516                    Ok(Ok((stream, _))) => Ok(stream),
1517                    Ok(Err(e)) => Err(map_connect_error(e, &url)),
1518                    Err(_) => Err(CamelError::ProcessorError(format!(
1519                        "WebSocket connect timeout ({connect_timeout:?}) to {url}"
1520                    ))),
1521                }
1522            }
1523        },
1524        is_retryable_ws_error,
1525    )
1526    .await
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531    use camel_component_api::test_support::PanicRuntimeObservability;
1532    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1533        std::sync::Arc::new(PanicRuntimeObservability)
1534    }
1535    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1536        std::sync::Arc::new(PanicRuntimeObservability)
1537    }
1538
1539    use super::*;
1540    use camel_component_api::NoOpComponentContext;
1541    use std::time::Duration;
1542
1543    use tokio::sync::mpsc;
1544    use tokio_tungstenite::connect_async;
1545    use tokio_tungstenite::tungstenite::Message as ClientMessage;
1546    use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
1547    use tokio_util::sync::CancellationToken;
1548    use tower::ServiceExt;
1549
1550    fn free_port() -> u16 {
1551        std::net::TcpListener::bind("127.0.0.1:0")
1552            .unwrap()
1553            .local_addr()
1554            .unwrap()
1555            .port()
1556    }
1557
1558    #[test]
1559    fn ws_component_scheme_is_ws() {
1560        assert_eq!(WsComponent::new().scheme(), "ws");
1561    }
1562
1563    #[test]
1564    fn wss_component_scheme_is_wss() {
1565        assert_eq!(WssComponent::new().scheme(), "wss");
1566    }
1567
1568    #[test]
1569    fn endpoint_config_defaults_match_spec() {
1570        let cfg = WsEndpointConfig::default();
1571        assert_eq!(cfg.scheme, "ws");
1572        assert_eq!(cfg.host, "0.0.0.0");
1573        assert_eq!(cfg.port, 8080);
1574        assert_eq!(cfg.path, "/");
1575        assert_eq!(cfg.max_connections, 100);
1576        assert_eq!(cfg.max_message_size, 65536);
1577        assert!(!cfg.send_to_all);
1578        assert_eq!(cfg.heartbeat_interval, Duration::ZERO);
1579        assert_eq!(cfg.idle_timeout, Duration::ZERO);
1580        assert_eq!(cfg.connect_timeout, Duration::from_secs(10));
1581        assert_eq!(cfg.response_timeout, Duration::from_secs(30));
1582        assert_eq!(cfg.allow_origin, "*");
1583        assert_eq!(cfg.tls_cert, None);
1584        assert_eq!(cfg.tls_key, None);
1585        assert!(cfg.reconnect);
1586        assert_eq!(cfg.reconnect_max_attempts, 5);
1587        assert_eq!(cfg.reconnect_delay_ms, 1000);
1588        assert_eq!(cfg.send_timeout, Duration::from_secs(30));
1589        assert!(!cfg.binary_payload);
1590        assert!(cfg.subprotocols.is_empty());
1591    }
1592
1593    #[test]
1594    fn endpoint_config_parses_uri_params() {
1595        let uri = "ws://localhost:9001/chat?maxConnections=42&maxMessageSize=1024&sendToAll=true&heartbeatIntervalMs=1500&idleTimeoutMs=2500&connectTimeoutMs=3500&responseTimeoutMs=4500&allowOrigin=https://example.com&tlsCert=/tmp/cert.pem&tlsKey=/tmp/key.pem";
1596        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
1597
1598        assert_eq!(cfg.scheme, "ws");
1599        assert_eq!(cfg.host, "localhost");
1600        assert_eq!(cfg.port, 9001);
1601        assert_eq!(cfg.path, "/chat");
1602        assert_eq!(cfg.max_connections, 42);
1603        assert_eq!(cfg.max_message_size, 1024);
1604        assert!(cfg.send_to_all);
1605        assert_eq!(cfg.heartbeat_interval, Duration::from_millis(1500));
1606        assert_eq!(cfg.idle_timeout, Duration::from_millis(2500));
1607        assert_eq!(cfg.connect_timeout, Duration::from_millis(3500));
1608        assert_eq!(cfg.response_timeout, Duration::from_millis(4500));
1609        assert_eq!(cfg.allow_origin, "https://example.com");
1610        assert_eq!(cfg.tls_cert.as_deref(), Some("/tmp/cert.pem"));
1611        assert_eq!(cfg.tls_key.as_deref(), Some("/tmp/key.pem"));
1612        assert!(cfg.reconnect);
1613        assert_eq!(cfg.reconnect_max_attempts, 5);
1614        assert_eq!(cfg.reconnect_delay_ms, 1000);
1615    }
1616
1617    #[test]
1618    fn endpoint_config_parses_reconnect_uri_params() {
1619        let uri =
1620            "ws://localhost:9001/chat?reconnect=false&reconnectMaxAttempts=2&reconnectDelayMs=25";
1621        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
1622        assert!(!cfg.reconnect);
1623        assert_eq!(cfg.reconnect_max_attempts, 2);
1624        assert_eq!(cfg.reconnect_delay_ms, 25);
1625    }
1626
1627    #[test]
1628    fn endpoint_config_override_chain_uri_overrides_defaults() {
1629        let cfg = WsEndpointConfig::from_uri("ws://127.0.0.1:8089/echo?maxConnections=7").unwrap();
1630        assert_eq!(cfg.max_connections, 7);
1631        assert_eq!(cfg.max_message_size, 65536);
1632        assert!(!cfg.send_to_all);
1633        assert_eq!(cfg.response_timeout, Duration::from_secs(30));
1634    }
1635
1636    #[test]
1637    fn endpoint_trait_creates_consumer_and_producer() {
1638        let ctx = NoOpComponentContext;
1639        let endpoint = WsComponent::new()
1640            .create_endpoint("ws://127.0.0.1:9010/trait", &ctx)
1641            .unwrap();
1642
1643        endpoint.create_consumer(rt()).unwrap();
1644        endpoint
1645            .create_producer(rt(), &ProducerContext::default())
1646            .unwrap();
1647    }
1648
1649    #[test]
1650    fn ws_consumer_concurrency_model_uses_max_connections() {
1651        let cfg = WsEndpointConfig::from_uri("ws://127.0.0.1:9011/cm?maxConnections=321").unwrap();
1652        let consumer = WsConsumer::new(cfg.server_config(), test_rt());
1653        assert_eq!(
1654            consumer.concurrency_model(),
1655            ConcurrencyModel::Concurrent { max: Some(321) }
1656        );
1657    }
1658
1659    #[tokio::test]
1660    async fn connection_registry_add_remove_broadcast_and_targeted_send() {
1661        let registry = WsConnectionRegistry::new();
1662        let (tx1, mut rx1) = mpsc::channel(8);
1663        let (tx2, mut rx2) = mpsc::channel(8);
1664
1665        registry.insert("k1".into(), tx1);
1666        registry.insert("k2".into(), tx2);
1667        assert_eq!(registry.len(), 2);
1668
1669        for tx in registry.snapshot_senders() {
1670            tx.send(WsMessage::Text("broadcast".into())).await.unwrap();
1671        }
1672
1673        assert_eq!(rx1.recv().await, Some(WsMessage::Text("broadcast".into())));
1674        assert_eq!(rx2.recv().await, Some(WsMessage::Text("broadcast".into())));
1675
1676        let target = registry.get_senders_for_keys(&["k1".to_string()]);
1677        assert_eq!(target.len(), 1);
1678        target[0]
1679            .send(WsMessage::Text("targeted".into()))
1680            .await
1681            .unwrap();
1682
1683        assert_eq!(rx1.recv().await, Some(WsMessage::Text("targeted".into())));
1684        assert!(
1685            tokio::time::timeout(Duration::from_millis(50), rx2.recv())
1686                .await
1687                .is_err()
1688        );
1689
1690        registry.remove("k1");
1691        assert_eq!(registry.len(), 1);
1692    }
1693
1694    #[test]
1695    fn host_canonicalization_maps_local_hosts_to_loopback() {
1696        let c1 = WsEndpointConfig::from_uri("ws://0.0.0.0:9100/a")
1697            .unwrap()
1698            .canonical_host();
1699        let c2 = WsEndpointConfig::from_uri("ws://localhost:9101/b")
1700            .unwrap()
1701            .canonical_host();
1702        let c3 = WsEndpointConfig::from_uri("ws://127.0.0.1:9102/c")
1703            .unwrap()
1704            .canonical_host();
1705
1706        assert_eq!(c1, "127.0.0.1");
1707        assert_eq!(c2, "127.0.0.1");
1708        assert_eq!(c3, "127.0.0.1");
1709    }
1710
1711    #[tokio::test]
1712    async fn echo_flow_round_trips_message_through_consumer_and_producer() {
1713        let port = free_port();
1714        let uri = format!("ws://127.0.0.1:{port}/echo");
1715        let component_ctx = NoOpComponentContext;
1716        let endpoint = WsComponent::new()
1717            .create_endpoint(&uri, &component_ctx)
1718            .unwrap();
1719
1720        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1721        let producer = endpoint
1722            .create_producer(rt(), &ProducerContext::default())
1723            .unwrap();
1724
1725        let (route_tx, mut route_rx) = mpsc::channel(16);
1726        let ctx = ConsumerContext::new(
1727            route_tx,
1728            CancellationToken::new(),
1729            "ws-test-route".to_string(),
1730        );
1731        consumer.start(ctx).await.unwrap();
1732
1733        let route_task = tokio::spawn(async move {
1734            if let Some(envelope) = route_rx.recv().await {
1735                let payload = envelope
1736                    .exchange
1737                    .input
1738                    .body
1739                    .as_text()
1740                    .unwrap_or_default()
1741                    .to_string();
1742                let key = envelope
1743                    .exchange
1744                    .input
1745                    .header("CamelWsConnectionKey")
1746                    .and_then(|v| v.as_str())
1747                    .unwrap()
1748                    .to_string();
1749
1750                let mut response = Exchange::new(CamelMessage::new(CamelBody::Text(payload)));
1751                response
1752                    .input
1753                    .set_header("CamelWsConnectionKey", serde_json::Value::String(key));
1754                producer.oneshot(response).await.unwrap();
1755            }
1756        });
1757
1758        let url = format!("ws://127.0.0.1:{port}/echo");
1759        let (mut client, _) = loop {
1760            match connect_async(&url).await {
1761                Ok(ok) => break ok,
1762                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
1763            }
1764        };
1765
1766        client
1767            .send(ClientMessage::Text("hello-ws".into()))
1768            .await
1769            .unwrap();
1770
1771        let incoming = tokio::time::timeout(Duration::from_secs(2), async {
1772            loop {
1773                match client.next().await {
1774                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
1775                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
1776                    Some(Ok(_)) => continue,
1777                    Some(Err(e)) => panic!("ws receive failed: {e}"),
1778                    None => panic!("websocket closed before echo"),
1779                }
1780            }
1781        })
1782        .await
1783        .unwrap();
1784
1785        assert_eq!(incoming, "hello-ws");
1786
1787        consumer.stop().await.unwrap();
1788        route_task.await.unwrap();
1789    }
1790
1791    #[tokio::test]
1792    async fn consumer_stop_sends_close_1001() {
1793        let port = free_port();
1794        let uri = format!("ws://127.0.0.1:{port}/shutdown");
1795        let component_ctx = NoOpComponentContext;
1796        let endpoint = WsComponent::new()
1797            .create_endpoint(&uri, &component_ctx)
1798            .unwrap();
1799
1800        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1801        let (route_tx, _route_rx) = mpsc::channel(16);
1802        let ctx = ConsumerContext::new(
1803            route_tx,
1804            CancellationToken::new(),
1805            "ws-test-route".to_string(),
1806        );
1807        consumer.start(ctx).await.unwrap();
1808
1809        let url = format!("ws://127.0.0.1:{port}/shutdown");
1810        let (mut client, _) = loop {
1811            match connect_async(&url).await {
1812                Ok(ok) => break ok,
1813                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
1814            }
1815        };
1816
1817        client
1818            .send(ClientMessage::Text("keepalive".into()))
1819            .await
1820            .unwrap();
1821
1822        consumer.stop().await.unwrap();
1823
1824        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
1825            loop {
1826                match client.next().await {
1827                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
1828                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
1829                    Some(Ok(_)) => continue,
1830                    Some(Err(e)) => panic!("ws receive failed: {e}"),
1831                    None => panic!("websocket closed without close frame"),
1832                }
1833            }
1834        })
1835        .await
1836        .unwrap();
1837
1838        assert_eq!(close_code, Some(CloseCode::Away));
1839    }
1840
1841    #[test]
1842    fn wildcard_origin_allows_anything() {
1843        assert!(is_origin_allowed("*", None));
1844        assert!(is_origin_allowed("*", Some("https://example.com")));
1845    }
1846
1847    #[test]
1848    fn exact_origin_requires_match() {
1849        assert!(is_origin_allowed(
1850            "https://example.com",
1851            Some("https://example.com")
1852        ));
1853        assert!(!is_origin_allowed(
1854            "https://example.com",
1855            Some("https://other.com")
1856        ));
1857        assert!(!is_origin_allowed("https://example.com", None));
1858    }
1859
1860    #[test]
1861    fn endpoint_config_rejects_invalid_scheme() {
1862        let result = WsEndpointConfig::from_uri("http://localhost:9000/path");
1863        assert!(result.is_err());
1864        let msg = result.unwrap_err().to_string();
1865        assert!(
1866            msg.contains("Invalid WebSocket scheme"),
1867            "expected scheme error, got: {msg}"
1868        );
1869    }
1870
1871    #[tokio::test]
1872    async fn wss_consumer_start_fails_without_tls_cert() {
1873        let port = free_port();
1874        let component_ctx = NoOpComponentContext;
1875        let endpoint = WssComponent::new()
1876            .create_endpoint(&format!("wss://127.0.0.1:{port}/secure"), &component_ctx)
1877            .unwrap();
1878        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1879        let (tx, _rx) = mpsc::channel(16);
1880        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "ws-test-route".to_string());
1881        let result = consumer.start(ctx).await;
1882        assert!(result.is_err());
1883        let msg = result.unwrap_err().to_string();
1884        assert!(
1885            msg.contains("TLS cert path is required"),
1886            "expected TLS cert error, got: {msg}"
1887        );
1888    }
1889
1890    #[tokio::test]
1891    async fn wss_consumer_start_fails_with_nonexistent_cert() {
1892        let port = free_port();
1893        let component_ctx = NoOpComponentContext;
1894        let endpoint = WssComponent::new()
1895            .create_endpoint(&format!(
1896                "wss://127.0.0.1:{port}/secure?tlsCert=/nonexistent/cert.pem&tlsKey=/nonexistent/key.pem"
1897            ), &component_ctx)
1898            .unwrap();
1899        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1900        let (tx, _rx) = mpsc::channel(16);
1901        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "ws-test-route".to_string());
1902        let result = consumer.start(ctx).await;
1903        assert!(result.is_err());
1904        let msg = result.unwrap_err().to_string();
1905        assert!(
1906            msg.contains("TLS cert file error"),
1907            "expected cert file error, got: {msg}"
1908        );
1909    }
1910
1911    #[tokio::test]
1912    async fn server_registry_returns_same_state_for_same_port() {
1913        let port = free_port();
1914        let state1 = ServerRegistry::global()
1915            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
1916            .await
1917            .unwrap();
1918        let state2 = ServerRegistry::global()
1919            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
1920            .await
1921            .unwrap();
1922        assert!(
1923            Arc::ptr_eq(&state1.dispatch, &state2.dispatch),
1924            "expected same dispatch table for same port"
1925        );
1926    }
1927
1928    #[tokio::test]
1929    async fn dispatch_handler_returns_404_for_unregistered_path() {
1930        let port = free_port();
1931        let state = ServerRegistry::global()
1932            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
1933            .await
1934            .unwrap();
1935        let app = Router::new().fallback(dispatch_handler).with_state(state);
1936        let response = tokio::time::timeout(
1937            Duration::from_secs(2),
1938            tower::ServiceExt::oneshot(
1939                app,
1940                axum::http::Request::builder()
1941                    .uri("/nonexistent")
1942                    .body(Body::empty())
1943                    .unwrap(),
1944            ),
1945        )
1946        .await
1947        .unwrap()
1948        .unwrap();
1949        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1950    }
1951
1952    #[tokio::test]
1953    async fn client_mode_producer_connects_and_echoes() {
1954        let app = Router::new().route(
1955            "/echo",
1956            axum::routing::get(|ws: WebSocketUpgrade| async move {
1957                ws.on_upgrade(|mut socket: WebSocket| async move {
1958                    while let Some(Ok(msg)) = socket.recv().await {
1959                        match msg {
1960                            WsMessage::Text(text) => {
1961                                let _ = socket.send(WsMessage::Text(text)).await;
1962                            }
1963                            WsMessage::Binary(data) => {
1964                                let _ = socket.send(WsMessage::Binary(data)).await;
1965                            }
1966                            WsMessage::Close(_) => break,
1967                            _ => {}
1968                        }
1969                    }
1970                })
1971            }),
1972        );
1973        // Bind to port 0 directly to avoid TOCTOU race with free_port() + re-bind
1974        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1975        let port = listener.local_addr().unwrap().port();
1976        let server_task = tokio::spawn(async move {
1977            let _ = serve(listener, app).await;
1978        });
1979
1980        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/echo")).unwrap();
1981        let producer = WsProducer::new(cfg.client_config());
1982
1983        let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("hello-client".into())));
1984        tokio::time::sleep(Duration::from_millis(25)).await;
1985        let result =
1986            match tokio::time::timeout(Duration::from_secs(3), producer.oneshot(exchange)).await {
1987                Ok(Ok(r)) => r,
1988                Ok(Err(_)) => panic!("producer call failed"),
1989                Err(_) => panic!("producer call timed out"),
1990            };
1991
1992        assert_eq!(result.input.body.as_text().unwrap(), "hello-client");
1993
1994        server_task.abort();
1995    }
1996
1997    #[tokio::test]
1998    async fn max_connections_rejects_with_close_1013() {
1999        let port = free_port();
2000        let uri = format!("ws://127.0.0.1:{port}/limited?maxConnections=1");
2001        let component_ctx = NoOpComponentContext;
2002        let endpoint = WsComponent::new()
2003            .create_endpoint(&uri, &component_ctx)
2004            .unwrap();
2005        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2006        let (route_tx, _route_rx) = mpsc::channel(16);
2007        let ctx = ConsumerContext::new(
2008            route_tx,
2009            CancellationToken::new(),
2010            "ws-test-route".to_string(),
2011        );
2012        consumer.start(ctx).await.unwrap();
2013
2014        let url = format!("ws://127.0.0.1:{port}/limited");
2015        let (_client1, _) = loop {
2016            match connect_async(&url).await {
2017                Ok(ok) => break ok,
2018                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2019            }
2020        };
2021
2022        tokio::time::sleep(Duration::from_millis(100)).await;
2023
2024        let (mut client2, _) = connect_async(&url).await.unwrap();
2025
2026        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2027            loop {
2028                match client2.next().await {
2029                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2030                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2031                    Some(Ok(ClientMessage::Text(_))) => continue,
2032                    Some(Ok(_)) => continue,
2033                    Some(Err(e)) => panic!("client2 ws receive failed: {e}"),
2034                    None => panic!("client2 closed without close frame"),
2035                }
2036            }
2037        })
2038        .await
2039        .unwrap();
2040
2041        assert_eq!(
2042            close_code,
2043            Some(CloseCode::from(1013u16)),
2044            "expected 1013 (Try Again Later) for max connections"
2045        );
2046
2047        consumer.stop().await.unwrap();
2048    }
2049
2050    #[tokio::test]
2051    async fn max_message_size_rejects_with_close_1009() {
2052        let port = free_port();
2053        let uri = format!("ws://127.0.0.1:{port}/sizelimit?maxMessageSize=10");
2054        let component_ctx = NoOpComponentContext;
2055        let endpoint = WsComponent::new()
2056            .create_endpoint(&uri, &component_ctx)
2057            .unwrap();
2058        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2059        let (route_tx, _route_rx) = mpsc::channel(16);
2060        let ctx = ConsumerContext::new(
2061            route_tx,
2062            CancellationToken::new(),
2063            "ws-test-route".to_string(),
2064        );
2065        consumer.start(ctx).await.unwrap();
2066
2067        let url = format!("ws://127.0.0.1:{port}/sizelimit");
2068        let (mut client, _) = loop {
2069            match connect_async(&url).await {
2070                Ok(ok) => break ok,
2071                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2072            }
2073        };
2074
2075        let oversized = "x".repeat(100);
2076        client
2077            .send(ClientMessage::Text(oversized.into()))
2078            .await
2079            .unwrap();
2080
2081        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2082            loop {
2083                match client.next().await {
2084                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2085                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2086                    Some(Ok(_)) => continue,
2087                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2088                    None => panic!("websocket closed without close frame"),
2089                }
2090            }
2091        })
2092        .await
2093        .unwrap();
2094
2095        assert_eq!(
2096            close_code,
2097            Some(CloseCode::from(1009u16)),
2098            "expected 1009 (Message Too Big) for oversized message"
2099        );
2100
2101        consumer.stop().await.unwrap();
2102    }
2103
2104    #[tokio::test]
2105    async fn origin_rejection_returns_403() {
2106        let port = free_port();
2107        let uri = format!("ws://127.0.0.1:{port}/origintest?allowOrigin=https://allowed.com");
2108        let component_ctx = NoOpComponentContext;
2109        let endpoint = WsComponent::new()
2110            .create_endpoint(&uri, &component_ctx)
2111            .unwrap();
2112        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2113        let (route_tx, _route_rx) = mpsc::channel(16);
2114        let ctx = ConsumerContext::new(
2115            route_tx,
2116            CancellationToken::new(),
2117            "ws-test-route".to_string(),
2118        );
2119        consumer.start(ctx).await.unwrap();
2120
2121        let state = ServerRegistry::global()
2122            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2123            .await
2124            .unwrap();
2125        let app = Router::new().fallback(dispatch_handler).with_state(state);
2126
2127        let response = tokio::time::timeout(
2128            Duration::from_secs(2),
2129            tower::ServiceExt::oneshot(
2130                app,
2131                axum::http::Request::builder()
2132                    .uri("/origintest")
2133                    .header("origin", "https://evil.com")
2134                    .header("upgrade", "websocket")
2135                    .header("connection", "Upgrade")
2136                    .header("sec-websocket-version", "13")
2137                    .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
2138                    .body(Body::empty())
2139                    .unwrap(),
2140            ),
2141        )
2142        .await
2143        .unwrap()
2144        .unwrap();
2145
2146        assert_eq!(
2147            response.status(),
2148            StatusCode::FORBIDDEN,
2149            "expected 403 for disallowed origin"
2150        );
2151
2152        consumer.stop().await.unwrap();
2153    }
2154
2155    #[tokio::test]
2156    async fn broadcast_sends_to_all_connected_clients() {
2157        let port = free_port();
2158        let uri = format!("ws://127.0.0.1:{port}/bc");
2159        let component_ctx = NoOpComponentContext;
2160        let endpoint = WsComponent::new()
2161            .create_endpoint(&uri, &component_ctx)
2162            .unwrap();
2163        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2164        let producer = endpoint
2165            .create_producer(rt(), &ProducerContext::default())
2166            .unwrap();
2167
2168        let (route_tx, _route_rx) = mpsc::channel(16);
2169        let ctx = ConsumerContext::new(
2170            route_tx,
2171            CancellationToken::new(),
2172            "ws-test-route".to_string(),
2173        );
2174        consumer.start(ctx).await.unwrap();
2175
2176        let url = format!("ws://127.0.0.1:{port}/bc");
2177
2178        let (mut client1, _) = loop {
2179            match connect_async(&url).await {
2180                Ok(ok) => break ok,
2181                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2182            }
2183        };
2184
2185        let (mut client2, _) = connect_async(&url).await.unwrap();
2186
2187        tokio::time::sleep(Duration::from_millis(100)).await;
2188
2189        let mut response =
2190            Exchange::new(CamelMessage::new(CamelBody::Text("broadcast-msg".into())));
2191        response
2192            .input
2193            .set_header("CamelWsSendToAll", serde_json::Value::Bool(true));
2194        producer.oneshot(response).await.unwrap();
2195
2196        let recv1 = tokio::time::timeout(Duration::from_secs(2), async {
2197            loop {
2198                match client1.next().await {
2199                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
2200                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2201                    _ => panic!("client1 unexpected message or close"),
2202                }
2203            }
2204        })
2205        .await
2206        .unwrap();
2207
2208        let recv2 = tokio::time::timeout(Duration::from_secs(2), async {
2209            loop {
2210                match client2.next().await {
2211                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
2212                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2213                    _ => panic!("client2 unexpected message or close"),
2214                }
2215            }
2216        })
2217        .await
2218        .unwrap();
2219
2220        assert_eq!(recv1, "broadcast-msg");
2221        assert_eq!(recv2, "broadcast-msg");
2222
2223        consumer.stop().await.unwrap();
2224    }
2225
2226    #[tokio::test]
2227    async fn concurrent_get_or_spawn_returns_same_state() {
2228        let port = free_port();
2229        let results: Arc<std::sync::Mutex<Vec<WsAppState>>> =
2230            Arc::new(std::sync::Mutex::new(Vec::new()));
2231
2232        let mut handles = Vec::new();
2233        for _ in 0..4 {
2234            let results = results.clone();
2235            handles.push(tokio::spawn(async move {
2236                let state = ServerRegistry::global()
2237                    .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2238                    .await
2239                    .unwrap();
2240                results.lock().unwrap().push(state);
2241            }));
2242        }
2243
2244        for h in handles {
2245            h.await.unwrap();
2246        }
2247
2248        let states = results.lock().unwrap();
2249        assert_eq!(states.len(), 4);
2250        for i in 1..states.len() {
2251            assert!(
2252                Arc::ptr_eq(&states[0].dispatch, &states[i].dispatch),
2253                "all concurrent callers should get the same dispatch table"
2254            );
2255        }
2256    }
2257
2258    #[tokio::test]
2259    async fn body_conversion_helpers_cover_text_and_binary_paths() {
2260        let text_msg = body_to_axum_ws_message(CamelBody::Text("abc".into()), "text")
2261            .await
2262            .unwrap();
2263        assert!(matches!(text_msg, WsMessage::Text(_)));
2264
2265        let bin_msg = body_to_axum_ws_message(CamelBody::Bytes(vec![1, 2, 3].into()), "binary")
2266            .await
2267            .unwrap();
2268        assert!(matches!(bin_msg, WsMessage::Binary(_)));
2269
2270        let client_text =
2271            body_to_client_ws_message(CamelBody::Json(serde_json::json!({"k":"v"})), "text")
2272                .await
2273                .unwrap();
2274        assert!(matches!(client_text, ClientWsMessage::Text(_)));
2275
2276        let client_bin = body_to_client_ws_message(CamelBody::Bytes(vec![7, 8].into()), "binary")
2277            .await
2278            .unwrap();
2279        assert!(matches!(client_bin, ClientWsMessage::Binary(_)));
2280    }
2281
2282    #[tokio::test]
2283    async fn body_to_text_handles_empty_text_json_and_bytes() {
2284        assert_eq!(body_to_text(CamelBody::Empty).await.unwrap(), "");
2285        assert_eq!(
2286            body_to_text(CamelBody::Text("hello".into())).await.unwrap(),
2287            "hello"
2288        );
2289        assert_eq!(
2290            body_to_text(CamelBody::Json(serde_json::json!({"n":1})))
2291                .await
2292                .unwrap(),
2293            "{\"n\":1}"
2294        );
2295        assert_eq!(
2296            body_to_text(CamelBody::Bytes(b"hi".to_vec().into()))
2297                .await
2298                .unwrap(),
2299            "hi"
2300        );
2301    }
2302
2303    #[test]
2304    fn try_send_with_backpressure_returns_false_when_channel_full() {
2305        let (tx, _rx) = mpsc::channel::<WsMessage>(1);
2306        assert!(try_send_with_backpressure(
2307            &tx,
2308            WsMessage::Text("first".into()),
2309            "test"
2310        ));
2311        assert!(!try_send_with_backpressure(
2312            &tx,
2313            WsMessage::Text("second".into()),
2314            "test"
2315        ));
2316    }
2317
2318    #[test]
2319    fn map_connect_error_formats_connection_refused_and_generic_errors() {
2320        let refused = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
2321        let err = map_connect_error(tungstenite::Error::Io(refused), "ws://localhost:1/x");
2322        assert!(err.to_string().contains("WebSocket connection refused"));
2323
2324        let generic = map_connect_error(
2325            tungstenite::Error::Protocol(
2326                tokio_tungstenite::tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
2327            ),
2328            "ws://localhost:2/y",
2329        );
2330        assert!(
2331            generic
2332                .to_string()
2333                .contains("WebSocket connection failed (ws://localhost:2/y)")
2334        );
2335    }
2336
2337    // === Phase B Finding Tests ===
2338
2339    // WS-015: maxConnections=0 must be rejected
2340    #[test]
2341    fn from_uri_rejects_max_connections_zero() {
2342        let result = WsEndpointConfig::from_uri("ws://localhost:9200/test?maxConnections=0");
2343        assert!(result.is_err());
2344        let msg = result.unwrap_err().to_string();
2345        assert!(
2346            msg.contains("maxConnections must be >= 1"),
2347            "expected maxConnections validation error, got: {msg}"
2348        );
2349    }
2350
2351    // WS-019: maxMessageSize=0 must be rejected
2352    #[test]
2353    fn from_uri_rejects_max_message_size_zero() {
2354        let result = WsEndpointConfig::from_uri("ws://localhost:9201/test?maxMessageSize=0");
2355        assert!(result.is_err());
2356        let msg = result.unwrap_err().to_string();
2357        assert!(
2358            msg.contains("maxMessageSize must be > 0"),
2359            "expected maxMessageSize validation error, got: {msg}"
2360        );
2361    }
2362
2363    // WS-020: allowOrigin="" must be rejected
2364    #[test]
2365    fn from_uri_rejects_empty_allow_origin() {
2366        let result = WsEndpointConfig::from_uri("ws://localhost:9202/test?allowOrigin=");
2367        assert!(result.is_err());
2368        let msg = result.unwrap_err().to_string();
2369        assert!(
2370            msg.contains("allowOrigin must not be empty"),
2371            "expected allowOrigin validation error, got: {msg}"
2372        );
2373    }
2374
2375    // WS-006: Double-start must be rejected
2376    #[tokio::test]
2377    async fn consumer_double_start_returns_error() {
2378        let port = free_port();
2379        let uri = format!("ws://127.0.0.1:{port}/doublestart");
2380        let component_ctx = NoOpComponentContext;
2381        let endpoint = WsComponent::new()
2382            .create_endpoint(&uri, &component_ctx)
2383            .unwrap();
2384
2385        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2386        let (route_tx, _route_rx) = mpsc::channel(16);
2387        let ctx = ConsumerContext::new(
2388            route_tx,
2389            CancellationToken::new(),
2390            "ws-test-route".to_string(),
2391        );
2392
2393        // First start should succeed
2394        consumer.start(ctx).await.unwrap();
2395
2396        // Second start should fail
2397        let (route_tx2, _route_rx2) = mpsc::channel(16);
2398        let ctx2 = ConsumerContext::new(
2399            route_tx2,
2400            CancellationToken::new(),
2401            "ws-test-route-2".to_string(),
2402        );
2403        let result = consumer.start(ctx2).await;
2404        assert!(result.is_err());
2405        let msg = result.unwrap_err().to_string();
2406        assert!(
2407            msg.contains("already started"),
2408            "expected double-start error, got: {msg}"
2409        );
2410
2411        consumer.stop().await.unwrap();
2412    }
2413
2414    // WS-005: Registry cleanup on stop + port reuse
2415    #[tokio::test]
2416    async fn registry_cleanup_on_consumer_stop() {
2417        let port = free_port();
2418        let uri = format!("ws://127.0.0.1:{port}/cleanup");
2419        let component_ctx = NoOpComponentContext;
2420        let endpoint = WsComponent::new()
2421            .create_endpoint(&uri, &component_ctx)
2422            .unwrap();
2423
2424        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2425        let (route_tx, _route_rx) = mpsc::channel(16);
2426        let ctx = ConsumerContext::new(
2427            route_tx,
2428            CancellationToken::new(),
2429            "ws-test-route".to_string(),
2430        );
2431        consumer.start(ctx).await.unwrap();
2432
2433        // Verify registry entry exists
2434        let registries = global_registries();
2435        let key = ("127.0.0.1".to_string(), port, "/cleanup".to_string());
2436        assert!(
2437            registries.contains_key(&key),
2438            "registry should have entry after start"
2439        );
2440
2441        // Stop consumer
2442        consumer.stop().await.unwrap();
2443
2444        // Verify registry entry is removed
2445        assert!(
2446            !registries.contains_key(&key),
2447            "registry should be cleaned up after stop"
2448        );
2449
2450        // Verify server registry reference is released (port can be reused)
2451        // The ServerRegistry should have removed the entry
2452        let server_reg = ServerRegistry::global();
2453        let guard = server_reg.inner.lock().unwrap();
2454        assert!(
2455            !guard.contains_key(&port),
2456            "ServerRegistry should remove port entry after last consumer stops"
2457        );
2458    }
2459
2460    // WS-003 + WS-004: poll_ready backpressure and server-send error handling
2461    #[tokio::test]
2462    async fn producer_server_send_returns_error_when_all_dropped() {
2463        let port = free_port();
2464        let uri = format!("ws://127.0.0.1:{port}/backpressure");
2465        let component_ctx = NoOpComponentContext;
2466        let endpoint = WsComponent::new()
2467            .create_endpoint(&uri, &component_ctx)
2468            .unwrap();
2469
2470        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2471        let producer = endpoint
2472            .create_producer(rt(), &ProducerContext::default())
2473            .unwrap();
2474
2475        let (route_tx, _route_rx) = mpsc::channel(1); // Tiny channel to force backpressure
2476        let ctx = ConsumerContext::new(
2477            route_tx,
2478            CancellationToken::new(),
2479            "ws-test-route".to_string(),
2480        );
2481        consumer.start(ctx).await.unwrap();
2482
2483        // Connect a client so the registry has an entry
2484        let url = format!("ws://127.0.0.1:{port}/backpressure");
2485        let (mut client, _) = loop {
2486            match connect_async(&url).await {
2487                Ok(ok) => break ok,
2488                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2489            }
2490        };
2491
2492        // Don't consume messages — let the channel fill up
2493        tokio::time::sleep(Duration::from_millis(50)).await;
2494
2495        // Flood the channel to trigger backpressure
2496        let mut all_dropped = false;
2497        for _ in 0..100 {
2498            let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("flood".into())));
2499            match producer.clone().oneshot(exchange).await {
2500                Ok(_) => {}
2501                Err(e) => {
2502                    if e.to_string().contains("backpressure") {
2503                        all_dropped = true;
2504                        break;
2505                    }
2506                }
2507            }
2508        }
2509
2510        // The producer should eventually return a backpressure error
2511        assert!(
2512            all_dropped,
2513            "producer should return error when all messages are dropped due to backpressure"
2514        );
2515
2516        // Clean up
2517        let _ = client.close(None).await;
2518        consumer.stop().await.unwrap();
2519    }
2520
2521    // WS-012: Ping/pong round-trip in server mode
2522    #[tokio::test]
2523    async fn server_responds_to_client_ping_with_pong() {
2524        let port = free_port();
2525        let uri = format!("ws://127.0.0.1:{port}/pingpong");
2526        let component_ctx = NoOpComponentContext;
2527        let endpoint = WsComponent::new()
2528            .create_endpoint(&uri, &component_ctx)
2529            .unwrap();
2530
2531        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2532        let (route_tx, _route_rx) = mpsc::channel(16);
2533        let ctx = ConsumerContext::new(
2534            route_tx,
2535            CancellationToken::new(),
2536            "ws-test-route".to_string(),
2537        );
2538        consumer.start(ctx).await.unwrap();
2539
2540        let url = format!("ws://127.0.0.1:{port}/pingpong");
2541        let (mut client, _) = loop {
2542            match connect_async(&url).await {
2543                Ok(ok) => break ok,
2544                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2545            }
2546        };
2547
2548        // Send a ping
2549        client
2550            .send(ClientMessage::Ping(vec![1, 2, 3].into()))
2551            .await
2552            .unwrap();
2553
2554        // Expect a pong with the same payload
2555        let pong = tokio::time::timeout(Duration::from_secs(2), async {
2556            loop {
2557                match client.next().await {
2558                    Some(Ok(ClientMessage::Pong(data))) => break data,
2559                    Some(Ok(ClientMessage::Ping(_))) => continue,
2560                    Some(Ok(_)) => continue,
2561                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2562                    None => panic!("websocket closed before pong"),
2563                }
2564            }
2565        })
2566        .await
2567        .unwrap();
2568
2569        assert_eq!(pong, vec![1, 2, 3], "pong should echo ping payload");
2570
2571        consumer.stop().await.unwrap();
2572    }
2573
2574    // WS-008: Client-side retry on transient connect failures
2575    #[tokio::test]
2576    async fn producer_retries_on_connection_refused() {
2577        // Use a port that nothing is listening on
2578        let port = free_port();
2579        // Ensure nothing is on this port
2580        let cfg = WsEndpointConfig::from_uri(&format!(
2581            "ws://127.0.0.1:{port}/retry?reconnect=true&reconnectMaxAttempts=2&reconnectDelayMs=50"
2582        ))
2583        .unwrap();
2584        let producer = WsProducer::new(cfg.client_config());
2585
2586        let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("hello".into())));
2587
2588        // Should fail after retries (nothing listening)
2589        let result = tokio::time::timeout(Duration::from_secs(5), producer.oneshot(exchange)).await;
2590        assert!(
2591            result.is_ok(),
2592            "producer should complete (with error) within timeout"
2593        );
2594        let result = result.unwrap();
2595        assert!(
2596            result.is_err(),
2597            "producer should fail when nothing is listening"
2598        );
2599        let msg = result.unwrap_err().to_string();
2600        assert!(
2601            msg.contains("connection refused"),
2602            "expected connection refused error, got: {msg}"
2603        );
2604    }
2605
2606    // WS-001: Server bind error is visible (fake server-start error test)
2607    #[tokio::test]
2608    async fn server_bind_error_is_reported() {
2609        // Bind a port manually to cause a conflict
2610        let _listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2611        let port = _listener.local_addr().unwrap().port();
2612
2613        // Try to start a consumer on the same port — should succeed since axum binds lazily
2614        // The actual bind error happens when the server task runs
2615        let uri = format!("ws://127.0.0.1:{port}/binderror");
2616        let component_ctx = NoOpComponentContext;
2617        let endpoint = WsComponent::new()
2618            .create_endpoint(&uri, &component_ctx)
2619            .unwrap();
2620
2621        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2622        let (route_tx, _route_rx) = mpsc::channel(16);
2623        let ctx = ConsumerContext::new(
2624            route_tx,
2625            CancellationToken::new(),
2626            "ws-test-route".to_string(),
2627        );
2628
2629        // Start should succeed (server spawns, but bind may fail)
2630        let start_result = consumer.start(ctx).await;
2631        // The server may or may not have bound yet — this test verifies no panic
2632        // The actual error is logged by the server task
2633        let _ = start_result;
2634
2635        consumer.stop().await.unwrap();
2636    }
2637
2638    #[test]
2639    fn ws_app_state_server_error_starts_false() {
2640        let state = WsAppState {
2641            dispatch: Arc::new(RwLock::new(HashMap::new())),
2642            path_configs: Arc::new(DashMap::new()),
2643            path_policies: Arc::new(DashMap::new()),
2644            server_error: new_atomic_false(),
2645            runtime: test_rt(),
2646            route_id: "test-route".into(),
2647        };
2648        assert!(
2649            !state.server_error.load(Ordering::Relaxed),
2650            "server_error should start as false"
2651        );
2652    }
2653
2654    #[test]
2655    fn ws_app_state_server_error_can_be_set() {
2656        let state = WsAppState {
2657            dispatch: Arc::new(RwLock::new(HashMap::new())),
2658            path_configs: Arc::new(DashMap::new()),
2659            path_policies: Arc::new(DashMap::new()),
2660            server_error: new_atomic_false(),
2661            runtime: test_rt(),
2662            route_id: "test-route".into(),
2663        };
2664        assert!(!state.server_error.load(Ordering::Relaxed));
2665        state.server_error.store(true, Ordering::Relaxed);
2666        assert!(state.server_error.load(Ordering::Relaxed));
2667    }
2668
2669    #[tokio::test]
2670    async fn consumer_stop_returns_error_when_server_had_errors() {
2671        let port = free_port();
2672        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/errorflag")).unwrap();
2673        let mut consumer = WsConsumer::new(cfg.server_config(), test_rt());
2674        let (route_tx, _route_rx) = mpsc::channel(16);
2675        let ctx = ConsumerContext::new(
2676            route_tx,
2677            CancellationToken::new(),
2678            "ws-test-route".to_string(),
2679        );
2680        consumer.start(ctx).await.unwrap();
2681
2682        // Simulate server error by setting the flag directly
2683        if let Some(ref state) = consumer.server_state {
2684            state.server_error.store(true, Ordering::Relaxed);
2685        }
2686
2687        let result = consumer.stop().await;
2688        assert!(
2689            result.is_err(),
2690            "stop should return error when server had errors"
2691        );
2692        let msg = result.unwrap_err().to_string();
2693        assert!(
2694            msg.contains("terminated with errors"),
2695            "expected server error message, got: {msg}"
2696        );
2697    }
2698
2699    #[tokio::test]
2700    async fn consumer_stop_succeeds_when_server_healthy() {
2701        let port = free_port();
2702        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/healthy")).unwrap();
2703        let mut consumer = WsConsumer::new(cfg.server_config(), test_rt());
2704        let (route_tx, _route_rx) = mpsc::channel(16);
2705        let ctx = ConsumerContext::new(
2706            route_tx,
2707            CancellationToken::new(),
2708            "ws-test-route".to_string(),
2709        );
2710        consumer.start(ctx).await.unwrap();
2711
2712        let result = consumer.stop().await;
2713        assert!(
2714            result.is_ok(),
2715            "stop should succeed when server is healthy: {:?}",
2716            result
2717        );
2718    }
2719
2720    // === H-10 Finding Tests ===
2721
2722    // WS-007: subprotocol negotiation support
2723    #[test]
2724    fn endpoint_config_parses_subprotocols() {
2725        let cfg = WsEndpointConfig::from_uri(
2726            "ws://localhost:9001/chat?subprotocols=graphql-ws,graphql-transport-ws",
2727        )
2728        .unwrap();
2729        assert_eq!(cfg.subprotocols, vec!["graphql-ws", "graphql-transport-ws"]);
2730    }
2731
2732    #[test]
2733    fn endpoint_config_default_subprotocols_empty() {
2734        let cfg = WsEndpointConfig::default();
2735        assert!(cfg.subprotocols.is_empty());
2736    }
2737
2738    // WS-017: sendTimeoutMs URI option
2739    #[test]
2740    fn endpoint_config_parses_send_timeout() {
2741        let cfg =
2742            WsEndpointConfig::from_uri("ws://localhost:9001/chat?sendTimeoutMs=5000").unwrap();
2743        assert_eq!(cfg.send_timeout, Duration::from_millis(5000));
2744    }
2745
2746    #[test]
2747    fn endpoint_config_default_send_timeout() {
2748        let cfg = WsEndpointConfig::default();
2749        assert_eq!(cfg.send_timeout, Duration::from_secs(30));
2750    }
2751
2752    #[test]
2753    fn endpoint_config_rejects_invalid_send_timeout() {
2754        let err =
2755            WsEndpointConfig::from_uri("ws://localhost:9001/chat?sendTimeoutMs=abc").unwrap_err();
2756        assert!(err.to_string().contains("sendTimeoutMs"));
2757    }
2758
2759    // WS-018: binaryPayload URI option
2760    #[test]
2761    fn endpoint_config_parses_binary_payload() {
2762        let cfg =
2763            WsEndpointConfig::from_uri("ws://localhost:9001/chat?binaryPayload=true").unwrap();
2764        assert!(cfg.binary_payload);
2765    }
2766
2767    #[test]
2768    fn endpoint_config_default_binary_payload_false() {
2769        let cfg = WsEndpointConfig::default();
2770        assert!(!cfg.binary_payload);
2771    }
2772
2773    #[test]
2774    fn endpoint_config_rejects_invalid_binary_payload() {
2775        let err =
2776            WsEndpointConfig::from_uri("ws://localhost:9001/chat?binaryPayload=yes").unwrap_err();
2777        assert!(err.to_string().contains("binaryPayload"));
2778    }
2779
2780    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
2781    /// Replicates the exact retry loop from the WebSocket producer connect (lib.rs:~1228-1275):
2782    ///   attempts starts at 0, should_retry(attempts+1), delay_for(attempts), attempts += 1
2783    #[tokio::test]
2784    async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
2785        use camel_component_api::NetworkRetryPolicy;
2786        use std::sync::Arc;
2787        use std::sync::atomic::{AtomicU32, Ordering};
2788
2789        let policy = NetworkRetryPolicy {
2790            max_attempts: 3,
2791            initial_delay: Duration::from_millis(1),
2792            max_delay: Duration::from_millis(1),
2793            multiplier: 1.0,
2794            ..NetworkRetryPolicy::default()
2795        };
2796
2797        let calls = Arc::new(AtomicU32::new(0));
2798        let calls_clone = Arc::clone(&calls);
2799        let mut attempts: u32 = 0;
2800
2801        let _result: Result<(), ()> = loop {
2802            calls_clone.fetch_add(1, Ordering::SeqCst);
2803            let op_result: Result<(), ()> = Err(());
2804            match op_result {
2805                Ok(_) => unreachable!(),
2806                Err(_) if policy.should_retry(attempts + 1) => {
2807                    let delay = policy.delay_for(attempts);
2808                    tokio::time::sleep(delay).await;
2809                    attempts += 1;
2810                    continue;
2811                }
2812                Err(_) => break Err(()),
2813            }
2814        };
2815
2816        assert_eq!(
2817            calls.load(Ordering::SeqCst),
2818            3,
2819            "max_attempts=3 must yield exactly 3 invocations"
2820        );
2821    }
2822
2823    /// Edge case: max_attempts=1 → exactly 1 invocation (initial attempt only, no retry).
2824    /// Locks the edge that originally broke OpenSearch.
2825    #[tokio::test]
2826    async fn retry_loop_with_max_attempts_1_invokes_operation_once() {
2827        use camel_component_api::NetworkRetryPolicy;
2828        use std::sync::Arc;
2829        use std::sync::atomic::{AtomicU32, Ordering};
2830
2831        let policy = NetworkRetryPolicy {
2832            max_attempts: 1,
2833            initial_delay: Duration::from_millis(1),
2834            max_delay: Duration::from_millis(1),
2835            multiplier: 1.0,
2836            ..NetworkRetryPolicy::default()
2837        };
2838
2839        let calls = Arc::new(AtomicU32::new(0));
2840        let calls_clone = Arc::clone(&calls);
2841        let mut attempts: u32 = 0;
2842
2843        let _result: Result<(), ()> = loop {
2844            calls_clone.fetch_add(1, Ordering::SeqCst);
2845            let op_result: Result<(), ()> = Err(());
2846            match op_result {
2847                Ok(_) => unreachable!(),
2848                Err(_) if policy.should_retry(attempts + 1) => {
2849                    let delay = policy.delay_for(attempts);
2850                    tokio::time::sleep(delay).await;
2851                    attempts += 1;
2852                    continue;
2853                }
2854                Err(_) => break Err(()),
2855            }
2856        };
2857
2858        assert_eq!(
2859            calls.load(Ordering::SeqCst),
2860            1,
2861            "max_attempts=1 must yield exactly 1 invocation"
2862        );
2863    }
2864
2865    // ── rc-1nm regression: WS producer retry emits component=ws-producer ──
2866
2867    use std::fmt::Write as _;
2868    use std::sync::{Arc, Mutex};
2869    use tracing::Subscriber;
2870    use tracing_subscriber::Layer;
2871    use tracing_subscriber::layer::SubscriberExt;
2872
2873    struct CollectingLayer {
2874        events: Arc<Mutex<Vec<String>>>,
2875    }
2876
2877    impl<S: Subscriber> Layer<S> for CollectingLayer {
2878        fn on_event(
2879            &self,
2880            event: &tracing::Event<'_>,
2881            _ctx: tracing_subscriber::layer::Context<'_, S>,
2882        ) {
2883            let mut buf = String::new();
2884            let mut visitor = CollectingVisitor { fields: &mut buf };
2885            event.record(&mut visitor);
2886            if let Ok(mut events) = self.events.lock() {
2887                events.push(buf);
2888            }
2889        }
2890    }
2891
2892    struct CollectingVisitor<'a> {
2893        fields: &'a mut String,
2894    }
2895
2896    impl CollectingVisitor<'_> {
2897        fn record_field(&mut self, name: &str, value: &str) {
2898            write!(self.fields, " {name}={value}").ok();
2899        }
2900    }
2901
2902    impl tracing::field::Visit for CollectingVisitor<'_> {
2903        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
2904            self.record_field(field.name(), value);
2905        }
2906        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
2907            self.record_field(field.name(), &format!("{value:?}"));
2908        }
2909        fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
2910            self.record_field(field.name(), &value.to_string());
2911        }
2912    }
2913
2914    /// Regression for rc-1nm: the WS producer retry path must emit
2915    /// `component=ws-producer` in retry log events so operators can
2916    /// identify which component is retrying.
2917    ///
2918    /// Drives `retry_async` directly with `Some("ws-producer")` and a
2919    /// deterministic retryable error. An earlier version exercised the
2920    /// production `connect_ws_with_retry` helper against `ws://127.0.0.1:1`,
2921    /// but that was flaky under heavy workspace load: the thread-local
2922    /// tracing subscriber (`set_default`) very occasionally missed the
2923    /// event logged from within the async connect path (the warn! is
2924    /// always emitted — `map_connect_error` always yields a retryable
2925    /// string for `ws://` — so the miss was purely a capture race).
2926    /// Driving `retry_async` synchronously with a synthetic op removes the
2927    /// network I/O and reactor scheduling, so the warn! is always emitted
2928    /// and captured on the test thread.
2929    #[tokio::test]
2930    async fn ws_producer_retry_log_emits_component_ws_producer() {
2931        let events = Arc::new(Mutex::new(Vec::new()));
2932        let layer = CollectingLayer {
2933            events: events.clone(),
2934        };
2935        let subscriber = tracing_subscriber::registry().with(layer);
2936        let _guard = tracing::subscriber::set_default(subscriber);
2937
2938        let policy = NetworkRetryPolicy {
2939            max_attempts: 2,
2940            initial_delay: Duration::from_millis(1),
2941            max_delay: Duration::from_millis(5),
2942            ..NetworkRetryPolicy::default()
2943        };
2944
2945        // Deterministic retryable failure (string recognised by
2946        // is_retryable_ws_error) — no network I/O, so the retry warn! is
2947        // emitted and captured synchronously on this thread.
2948        let result: Result<(), CamelError> = retry_async(
2949            &policy,
2950            Some("ws-producer"),
2951            || async {
2952                Err(CamelError::ProcessorError(
2953                    "WebSocket connection refused: simulated".to_string(),
2954                ))
2955            },
2956            is_retryable_ws_error,
2957        )
2958        .await;
2959
2960        assert!(result.is_err(), "expected exhausted-retries error");
2961        let captured = events.lock().unwrap();
2962        assert!(
2963            !captured.is_empty(),
2964            "expected at least one retry log event, got none"
2965        );
2966        let first = &captured[0];
2967        assert!(
2968            first.contains("component=ws-producer"),
2969            "rc-1nm regression: expected 'component=ws-producer' in WS retry log, got: {first}"
2970        );
2971    }
2972}