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