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