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