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