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