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