1use crate::{
6 DEFAULT_USER_AGENT, REQUEST_TIMEOUT,
7 provider::{
8 mpp::transport::{LazyMppHttpTransport, lazy_mpp_ws_connect},
9 redact_url,
10 },
11};
12use alloy_json_rpc::{RequestPacket, ResponsePacket};
13use alloy_pubsub::{PubSubConnect, PubSubFrontend};
14use alloy_rpc_types_engine::{Claims, JwtSecret};
15use alloy_transport::{
16 Authorization, BoxTransport, TransportError, TransportErrorKind, TransportFut,
17 utils::guess_local_url,
18};
19use alloy_transport_ipc::IpcConnect;
20use alloy_transport_ws::WsConnect;
21use regex::{Captures, Regex};
22use reqwest::header::{HeaderName, HeaderValue};
23use std::{
24 error::Error as StdError,
25 fmt,
26 path::PathBuf,
27 str::FromStr,
28 sync::{Arc, LazyLock},
29};
30use thiserror::Error;
31use tokio::{
32 runtime::{Handle, Id},
33 sync::RwLock,
34};
35use tower::Service;
36use url::Url;
37
38const KNOWN_MPP_HOSTS: &[&str] = &[".mpp.tempo.xyz", ".mpp.moderato.tempo.xyz"];
43
44static HTTP_URL_RE: LazyLock<Regex> =
45 LazyLock::new(|| Regex::new(r#"(?i)https?://[^\s<>"']+"#).expect("valid URL regex"));
46
47#[derive(Clone, Debug)]
50pub enum InnerTransport {
51 Http(LazyMppHttpTransport),
57 Ws(PubSubFrontend),
59 Ipc(PubSubFrontend),
61}
62
63#[derive(Error, Debug)]
65pub enum RuntimeTransportError {
66 #[error("Internal transport error: {0} with {1}")]
68 TransportError(TransportError, String),
69
70 #[error("URL scheme is not supported: {0}")]
72 BadScheme(String),
73
74 #[error("Invalid HTTP header: {0}")]
76 BadHeader(String),
77
78 #[error("Invalid IPC file path: {0}")]
80 BadPath(String),
81
82 #[error(transparent)]
84 HttpConstructionError(#[from] reqwest::Error),
85
86 #[error("Invalid JWT: {0}")]
88 InvalidJwt(String),
89}
90
91#[derive(Clone, Debug)]
99pub struct RuntimeTransport {
100 inner: Arc<RwLock<Option<(Id, InnerTransport)>>>,
102 url: Url,
104 headers: Vec<String>,
106 jwt: Option<String>,
108 timeout: std::time::Duration,
110 accept_invalid_certs: bool,
112 no_proxy: bool,
114}
115
116#[derive(Debug)]
118pub struct RuntimeTransportBuilder {
119 url: Url,
120 headers: Vec<String>,
121 jwt: Option<String>,
122 timeout: std::time::Duration,
123 accept_invalid_certs: bool,
124 no_proxy: bool,
125}
126
127impl RuntimeTransportBuilder {
128 pub const fn new(url: Url) -> Self {
130 Self {
131 url,
132 headers: vec![],
133 jwt: None,
134 timeout: REQUEST_TIMEOUT,
135 accept_invalid_certs: false,
136 no_proxy: false,
137 }
138 }
139
140 pub fn with_headers(mut self, headers: Vec<String>) -> Self {
142 self.headers = headers;
143 self
144 }
145
146 pub fn with_jwt(mut self, jwt: Option<String>) -> Self {
148 self.jwt = jwt;
149 self
150 }
151
152 pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
154 self.timeout = timeout;
155 self
156 }
157
158 pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
160 self.accept_invalid_certs = accept_invalid_certs;
161 self
162 }
163
164 pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
169 self.no_proxy = no_proxy;
170 self
171 }
172
173 pub fn build(self) -> RuntimeTransport {
176 RuntimeTransport {
177 inner: Arc::new(RwLock::new(None)),
178 url: self.url,
179 headers: self.headers,
180 jwt: self.jwt,
181 timeout: self.timeout,
182 accept_invalid_certs: self.accept_invalid_certs,
183 no_proxy: self.no_proxy,
184 }
185 }
186}
187
188impl fmt::Display for RuntimeTransport {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 write!(f, "RuntimeTransport {}", redact_url(self.url.as_str()))
191 }
192}
193
194impl RuntimeTransport {
195 pub async fn connect(&self) -> Result<InnerTransport, RuntimeTransportError> {
197 match self.url.scheme() {
198 "http" | "https" => self.connect_http(),
199 "ws" | "wss" => self.connect_ws().await,
200 "file" => self.connect_ipc().await,
201 _ => Err(RuntimeTransportError::BadScheme(self.url.scheme().to_string())),
202 }
203 }
204
205 fn reqwest_headers(&self) -> Result<reqwest::header::HeaderMap, RuntimeTransportError> {
206 let mut headers = reqwest::header::HeaderMap::new();
207
208 if let Some(jwt) = self.jwt.clone() {
210 let auth =
211 build_auth(jwt).map_err(|e| RuntimeTransportError::InvalidJwt(e.to_string()))?;
212
213 let mut auth_value: HeaderValue =
214 HeaderValue::from_str(&auth.to_string()).expect("Header should be valid string");
215 auth_value.set_sensitive(true);
216
217 headers.insert(reqwest::header::AUTHORIZATION, auth_value);
218 };
219
220 for header in &self.headers {
222 let make_err = || RuntimeTransportError::BadHeader(header.clone());
223
224 let (key, val) = header.split_once(':').ok_or_else(make_err)?;
225
226 headers.insert(
227 HeaderName::from_str(key.trim()).map_err(|_| make_err())?,
228 HeaderValue::from_str(val.trim()).map_err(|_| make_err())?,
229 );
230 }
231
232 if !headers.contains_key(reqwest::header::USER_AGENT) {
233 headers.insert(
234 reqwest::header::USER_AGENT,
235 HeaderValue::from_str(DEFAULT_USER_AGENT)
236 .expect("User-Agent should be valid string"),
237 );
238 }
239
240 if !headers.contains_key(HeaderName::from_static("x-api-key"))
243 && let Ok(api_key) = std::env::var("MPP_API_KEY")
244 {
245 let api_key = api_key.trim();
246 if !api_key.is_empty() {
247 let mut value = HeaderValue::from_str(api_key)
248 .map_err(|_| RuntimeTransportError::BadHeader("MPP_API_KEY".to_string()))?;
249 value.set_sensitive(true);
250 headers.insert(HeaderName::from_static("x-api-key"), value);
251 }
252 }
253
254 Ok(headers)
255 }
256
257 fn reqwest_client_with_headers(
258 &self,
259 headers: reqwest::header::HeaderMap,
260 ) -> Result<reqwest::Client, RuntimeTransportError> {
261 let mut client_builder = reqwest::Client::builder()
262 .timeout(self.timeout)
263 .danger_accept_invalid_certs(self.accept_invalid_certs);
264
265 if self.no_proxy || guess_local_url(self.url.as_str()) {
269 client_builder = client_builder.no_proxy();
270 }
271
272 client_builder = client_builder.default_headers(headers);
273
274 Ok(client_builder.build()?)
275 }
276
277 pub fn reqwest_client(&self) -> Result<reqwest::Client, RuntimeTransportError> {
279 self.reqwest_client_with_headers(self.reqwest_headers()?)
280 }
281
282 fn connect_http(&self) -> Result<InnerTransport, RuntimeTransportError> {
284 let headers = self.reqwest_headers()?;
285 let client = self.reqwest_client_with_headers(headers.clone())?;
286 Ok(InnerTransport::Http(LazyMppHttpTransport::lazy(client, self.url.clone(), headers)))
287 }
288
289 async fn connect_ws(&self) -> Result<InnerTransport, RuntimeTransportError> {
295 let auth = self
296 .jwt
297 .as_ref()
298 .map(|jwt| build_auth(jwt.clone()))
299 .transpose()
300 .map_err(|e| RuntimeTransportError::InvalidJwt(e.to_string()))?;
301
302 let service = if is_known_mpp_endpoint(&self.url) {
303 let mut ws = lazy_mpp_ws_connect(&self.url);
304 if let Some(auth) = auth {
305 ws = ws.with_auth(auth);
306 }
307 ws.into_service().await.map_err(|e| {
308 RuntimeTransportError::TransportError(e, redact_url(self.url.as_str()))
309 })?
310 } else {
311 let mut ws = WsConnect::new(self.url.to_string());
312 if let Some(auth) = auth {
313 ws = ws.with_auth(auth);
314 }
315 ws.into_service().await.map_err(|e| {
316 RuntimeTransportError::TransportError(e, redact_url(self.url.as_str()))
317 })?
318 };
319
320 Ok(InnerTransport::Ws(service))
321 }
322
323 async fn connect_ipc(&self) -> Result<InnerTransport, RuntimeTransportError> {
325 let path = url_to_file_path(&self.url)
326 .map_err(|_| RuntimeTransportError::BadPath(self.url.to_string()))?;
327 let ipc_connector = IpcConnect::new(path.clone());
328 let ipc = ipc_connector.into_service().await.map_err(|e| {
329 RuntimeTransportError::TransportError(e, path.clone().display().to_string())
330 })?;
331 Ok(InnerTransport::Ipc(ipc))
332 }
333
334 pub fn request(&self, req: RequestPacket) -> TransportFut<'static> {
342 let this = self.clone();
343 Box::pin(async move {
344 let runtime = Handle::current().id();
345 let inner = this.inner.read().await;
346 let transport = if let Some((id, transport)) = &*inner
347 && *id == runtime
348 {
349 let transport = transport.clone();
350 drop(inner);
351 transport
352 } else {
353 drop(inner);
354 let mut inner = this.inner.write().await;
355 if inner.as_ref().is_none_or(|(id, _)| *id != runtime) {
358 *inner =
359 Some((runtime, this.connect().await.map_err(TransportErrorKind::custom)?));
360 }
361 inner.as_ref().expect("transport is connected").1.clone()
362 };
363
364 match transport {
365 InnerTransport::Http(mut http) => http
366 .call(req)
367 .await
368 .map_err(|error| redact_http_transport_error(error, &this.url)),
369 InnerTransport::Ws(mut ws) => ws.call(req).await,
370 InnerTransport::Ipc(mut ipc) => ipc.call(req).await,
371 }
372 })
373 }
374
375 pub fn boxed(self) -> BoxTransport {
377 BoxTransport::new(self)
378 }
379}
380
381fn is_known_mpp_endpoint(url: &Url) -> bool {
383 url.host_str().is_some_and(|host| KNOWN_MPP_HOSTS.iter().any(|suffix| host.ends_with(suffix)))
384}
385
386fn redact_http_transport_error(error: TransportError, endpoint: &Url) -> TransportError {
387 let alloy_json_rpc::RpcError::Transport(TransportErrorKind::Custom(source)) = &error else {
388 return error;
389 };
390 let safe_endpoint = redact_url(endpoint.as_str());
391
392 let mut message = String::new();
393 let mut error: Option<&(dyn StdError + 'static)> = Some(source.as_ref());
394 while let Some(source) = error {
395 if !message.is_empty() {
396 message.push_str(": ");
397 }
398 message.push_str(&source.to_string());
399 error = source.source();
400 }
401 let message = HTTP_URL_RE.replace_all(&message, |captures: &Captures<'_>| {
402 let candidate = &captures[0];
403 let Ok(url) = Url::parse(candidate) else { return candidate.to_owned() };
404 if url.host() == endpoint.host()
405 && url.port_or_known_default() == endpoint.port_or_known_default()
406 {
407 safe_endpoint.clone()
408 } else {
409 candidate.to_owned()
410 }
411 });
412 TransportErrorKind::custom_str(&message)
413}
414
415impl tower::Service<RequestPacket> for RuntimeTransport {
416 type Response = ResponsePacket;
417 type Error = TransportError;
418 type Future = TransportFut<'static>;
419
420 #[inline]
421 fn poll_ready(
422 &mut self,
423 _cx: &mut std::task::Context<'_>,
424 ) -> std::task::Poll<Result<(), Self::Error>> {
425 std::task::Poll::Ready(Ok(()))
426 }
427
428 #[inline]
429 fn call(&mut self, req: RequestPacket) -> Self::Future {
430 self.request(req)
431 }
432}
433
434impl tower::Service<RequestPacket> for &RuntimeTransport {
435 type Response = ResponsePacket;
436 type Error = TransportError;
437 type Future = TransportFut<'static>;
438
439 #[inline]
440 fn poll_ready(
441 &mut self,
442 _cx: &mut std::task::Context<'_>,
443 ) -> std::task::Poll<Result<(), Self::Error>> {
444 std::task::Poll::Ready(Ok(()))
445 }
446
447 #[inline]
448 fn call(&mut self, req: RequestPacket) -> Self::Future {
449 self.request(req)
450 }
451}
452
453fn build_auth(jwt: String) -> eyre::Result<Authorization> {
454 let secret = JwtSecret::from_hex(jwt)?;
456 let claims = Claims::default();
457 let token = secret.encode(&claims)?;
458
459 let auth = Authorization::Bearer(token);
460
461 Ok(auth)
462}
463
464#[cfg(windows)]
465fn url_to_file_path(url: &Url) -> Result<PathBuf, ()> {
466 const PREFIX: &str = "file:///pipe/";
467
468 let url_str = url.as_str();
469
470 if let Some(pipe_name) = url_str.strip_prefix(PREFIX) {
471 let pipe_path = format!(r"\\.\pipe\{pipe_name}");
472 return Ok(PathBuf::from(pipe_path));
473 }
474
475 url.to_file_path()
476}
477
478#[cfg(not(windows))]
479fn url_to_file_path(url: &Url) -> Result<PathBuf, ()> {
480 url.to_file_path()
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use alloy_json_rpc::{Id as RpcId, Request};
487 use axum::{Json, Router, routing::post};
488 use reqwest::header::HeaderMap;
489 use serde_json::{Value, json};
490 use std::{io, time::Duration};
491 use tokio::{runtime::Builder, sync::Notify, time::timeout};
492
493 #[derive(Debug, Error)]
494 #[error("request to https://example.com/private-api-key failed")]
495 struct ProviderError {
496 #[source]
497 source: io::Error,
498 }
499
500 #[test]
501 fn http_transport_errors_preserve_provider_guidance() {
502 let endpoint =
503 Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
504 let error = TransportErrorKind::custom(ProviderError {
505 source: io::Error::other(
506 "Authorize an access key with:\n cast tempo login --no-browser",
507 ),
508 });
509
510 let report = redact_http_transport_error(error, &endpoint).to_string();
511
512 assert!(report.contains("https://example.com/"));
513 assert!(report.contains("cast tempo login --no-browser"));
514 assert!(!report.contains("password"));
515 assert!(!report.contains("private-api-key"));
516 assert!(!report.contains("secret"));
517 }
518
519 #[test]
520 fn http_transport_errors_redact_endpoint_paths() {
521 let endpoint =
522 Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
523 let error = TransportErrorKind::custom_str(concat!(
524 "request to https://example.com/private-api-key failed: connection refused\n\n",
525 "Authorize an access key with:\n cast tempo login"
526 ));
527
528 let error = redact_http_transport_error(error, &endpoint);
529 let report = error.to_string();
530
531 assert!(report.contains("https://example.com/"));
532 assert!(!report.contains("password"));
533 assert!(!report.contains("private-api-key"));
534 assert!(!report.contains("secret"));
535 assert!(report.to_lowercase().contains("connection refused"));
536 assert!(report.contains("cast tempo login"));
537 }
538
539 #[test]
540 fn http_transport_errors_redact_normalized_endpoint_variants() {
541 let endpoint =
542 Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap();
543 let error = TransportErrorKind::custom_str(
544 "request to https://USER:normalized@example.com:443/different%2Fpath?key=other failed",
545 );
546
547 let report = redact_http_transport_error(error, &endpoint).to_string();
548
549 assert!(report.contains("https://example.com/"));
550 assert!(!report.contains("normalized"));
551 assert!(!report.contains("different"));
552 assert!(!report.contains("other"));
553 }
554
555 #[tokio::test]
556 async fn websocket_error_redacts_url_credentials() {
557 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
558 let address = listener.local_addr().unwrap();
559 drop(listener);
560 let url = Url::parse(&format!(
561 "ws://user:password@{address}/private-api-key?token=secret#fragment"
562 ))
563 .unwrap();
564 let transport = RuntimeTransportBuilder::new(url).build();
565
566 let error = transport.connect_ws().await.unwrap_err().to_string();
567
568 assert!(error.contains(&format!("ws://{address}/")));
569 assert!(!error.contains("user"));
570 assert!(!error.contains("password"));
571 assert!(!error.contains("private-api-key"));
572 assert!(!error.contains("secret"));
573 }
574
575 #[tokio::test]
576 async fn test_user_agent_header() {
577 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
578 let url = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
579
580 let http_handler = axum::routing::get(|actual_headers: HeaderMap| {
581 let user_agent = HeaderName::from_str("User-Agent").unwrap();
582 assert_eq!(actual_headers[user_agent], HeaderValue::from_str("test-agent").unwrap());
583
584 async { "" }
585 });
586
587 let server_task = tokio::spawn(async move {
588 axum::serve(listener, http_handler.into_make_service()).await.unwrap()
589 });
590
591 let transport = RuntimeTransportBuilder::new(url.clone())
592 .with_headers(vec!["User-Agent: test-agent".to_string()])
593 .build();
594 let inner = transport.connect_http().unwrap();
595
596 match inner {
597 InnerTransport::Http(http) => {
598 let _ = http.client().get(url).send().await.unwrap();
599
600 }
602 _ => unreachable!(),
603 }
604
605 server_task.abort();
606 }
607
608 #[tokio::test]
609 async fn connect_rejects_invalid_jwt() {
610 for scheme in ["http", "https", "ws", "wss"] {
611 for jwt in ["not-hex", "00"] {
612 let url = Url::parse(&format!("{scheme}://127.0.0.1:0")).unwrap();
613 let transport =
614 RuntimeTransportBuilder::new(url).with_jwt(Some(jwt.to_string())).build();
615
616 let error = transport.connect().await.unwrap_err();
617
618 assert!(
619 matches!(error, RuntimeTransportError::InvalidJwt(_)),
620 "expected InvalidJwt for {scheme} with {jwt:?}, got {error:?}"
621 );
622 }
623 }
624 }
625
626 #[tokio::test]
627 async fn pending_request_does_not_block_another_runtime() {
628 let started = Arc::new(Notify::new());
629 let release = Arc::new(Notify::new());
630 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
631 let url = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
632 let router = Router::new().route(
633 "/",
634 post({
635 let started = started.clone();
636 let release = release.clone();
637 move |Json(request): Json<Value>| {
638 let started = started.clone();
639 let release = release.clone();
640 async move {
641 if request["method"] == "hold" {
642 started.notify_one();
643 release.notified().await;
644 }
645 Json(json!({"jsonrpc": "2.0", "id": request["id"], "result": "0x1"}))
646 }
647 }
648 }),
649 );
650 let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
651 let transport = RuntimeTransportBuilder::new(url).build();
652 let ready =
653 RequestPacket::from(Request::new("ready", RpcId::Number(1), ()).serialize().unwrap());
654 transport.request(ready.clone()).await.unwrap();
655 let hold = Request::new("hold", RpcId::Number(2), ()).serialize().unwrap().into();
656 let pending = tokio::spawn(transport.request(hold));
657 started.notified().await;
658
659 let result =
660 tokio::task::spawn_blocking(move || {
661 Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
662 timeout(Duration::from_secs(2), transport.request(ready)).await
663 })
664 })
665 .await
666 .unwrap();
667 release.notify_one();
668 pending.await.unwrap().unwrap();
669 server.abort();
670 result.expect("another runtime waited for an in-flight request").unwrap();
671 }
672}