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