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