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