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