Skip to main content

iroh_http_core/
endpoint.rs

1//! Iroh endpoint lifecycle — create, share, and close.
2
3use iroh::address_lookup::{DnsAddressLookup, PkarrPublisher};
4use iroh::endpoint::{IdleTimeout, QuicTransportConfig, TransportAddrUsage};
5use iroh::{Endpoint, RelayMode, SecretKey};
6use serde::{Deserialize, Serialize};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::pool::ConnectionPool;
12use crate::server::ServeHandle;
13use crate::stream::{HandleStore, StoreConfig};
14use crate::{ALPN, ALPN_DUPLEX};
15
16/// Networking / QUIC transport configuration.
17#[derive(Debug, Clone, Default)]
18pub struct NetworkingOptions {
19    /// Relay server mode. `"default"`, `"staging"`, `"disabled"`, or `"custom"`. Default: `"default"`.
20    pub relay_mode: Option<String>,
21    /// Custom relay server URLs. Only used when `relay_mode` is `"custom"`.
22    pub relays: Vec<String>,
23    /// UDP socket addresses to bind. Empty means OS-assigned.
24    pub bind_addrs: Vec<String>,
25    /// Milliseconds before an idle QUIC connection is cleaned up.
26    pub idle_timeout_ms: Option<u64>,
27    /// HTTP proxy URL for relay traffic.
28    pub proxy_url: Option<String>,
29    /// Read `HTTP_PROXY` / `HTTPS_PROXY` env vars for proxy config.
30    pub proxy_from_env: bool,
31    /// Disable relay servers and DNS discovery entirely. Overrides `relay_mode`.
32    /// Useful for in-process tests where endpoints connect via direct addresses.
33    pub disabled: bool,
34}
35
36/// DNS-based peer discovery configuration.
37#[derive(Debug, Clone)]
38pub struct DiscoveryOptions {
39    /// DNS discovery server URL. Uses n0 DNS defaults when `None`.
40    pub dns_server: Option<String>,
41    /// Whether to enable DNS discovery. Default: `true`.
42    pub enabled: bool,
43}
44
45impl Default for DiscoveryOptions {
46    fn default() -> Self {
47        Self {
48            dns_server: None,
49            enabled: true,
50        }
51    }
52}
53
54/// Connection-pool tuning.
55#[derive(Debug, Clone, Default)]
56pub struct PoolOptions {
57    /// Maximum number of idle connections to keep in the pool.
58    pub max_connections: Option<usize>,
59    /// Milliseconds a pooled connection may remain idle before being evicted.
60    pub idle_timeout_ms: Option<u64>,
61}
62
63/// Body-streaming and handle-store configuration.
64#[derive(Debug, Clone, Default)]
65pub struct StreamingOptions {
66    /// Capacity (in chunks) of each body channel. Default: 32.
67    pub channel_capacity: Option<usize>,
68    /// Maximum byte length of a single chunk in `send_chunk`. Default: 65536.
69    pub max_chunk_size_bytes: Option<usize>,
70    /// Milliseconds to wait for a slow body reader. Default: 30000.
71    pub drain_timeout_ms: Option<u64>,
72    /// TTL in ms for slab handle entries. `0` disables sweeping. Default: 300000.
73    pub handle_ttl_ms: Option<u64>,
74}
75
76/// Configuration passed to [`IrohEndpoint::bind`].
77#[derive(Debug, Clone, Default)]
78pub struct NodeOptions {
79    /// 32-byte Ed25519 secret key. Generate a fresh one when `None`.
80    pub key: Option<[u8; 32]>,
81    /// Networking / QUIC transport configuration.
82    pub networking: NetworkingOptions,
83    /// DNS-based peer discovery configuration.
84    pub discovery: DiscoveryOptions,
85    /// Connection-pool tuning.
86    pub pool: PoolOptions,
87    /// Body-streaming and handle-store configuration.
88    pub streaming: StreamingOptions,
89    /// ALPN capabilities to advertise. Empty = advertise iroh-http/2 and iroh-http/2-duplex.
90    pub capabilities: Vec<String>,
91    /// Write TLS session keys to $SSLKEYLOGFILE. Dev/debug only.
92    pub keylog: bool,
93    /// Maximum byte size of the HTTP/1.1 request or response head. `None` or `0` = 65536.
94    pub max_header_size: Option<usize>,
95    /// Server-side limits forwarded to the serve loop.
96    pub server_limits: crate::server::ServerLimits,
97    #[cfg(feature = "compression")]
98    pub compression: Option<CompressionOptions>,
99}
100
101/// Compression options for response bodies.
102/// Only used when the `compression` feature is enabled.
103#[cfg(feature = "compression")]
104#[derive(Debug, Clone)]
105pub struct CompressionOptions {
106    /// Minimum body size in bytes before compression is applied. Default: 512.
107    pub min_body_bytes: usize,
108    /// Zstd compression level (1–22). `None` uses the zstd default (3).
109    pub level: Option<u32>,
110}
111
112/// A shared Iroh endpoint.
113///
114/// Clone-able (cheap Arc clone).  All fetch and serve calls on the same node
115/// share one endpoint and therefore one stable QUIC identity.
116#[derive(Clone)]
117pub struct IrohEndpoint {
118    pub(crate) inner: Arc<EndpointInner>,
119}
120
121pub(crate) struct EndpointInner {
122    pub ep: Endpoint,
123    /// The node's own base32-encoded public key (stable for the lifetime of the key).
124    pub node_id_str: String,
125    /// Connection pool for reusing QUIC connections across fetch/connect calls.
126    pub pool: ConnectionPool,
127    /// Maximum byte size of an HTTP/1.1 head (request or response).
128    pub max_header_size: usize,
129    /// Server-side limits forwarded to the serve loop.
130    pub server_limits: crate::server::ServerLimits,
131    /// Per-endpoint handle store — owns all body readers, writers, trailers,
132    /// sessions, request-head channels, and fetch-cancel tokens.
133    pub handles: HandleStore,
134    /// Active serve handle, if `serve()` has been called.
135    pub serve_handle: std::sync::Mutex<Option<ServeHandle>>,
136    /// Done-signal receiver from the active serve task.
137    /// Stored separately so `wait_serve_stop()` can await it without holding
138    /// the `serve_handle` lock for the duration of the wait.
139    pub serve_done_rx: std::sync::Mutex<Option<tokio::sync::watch::Receiver<bool>>>,
140    /// Signals `true` when the endpoint has fully closed (either explicitly or
141    /// because the serve loop exited due to native shutdown).
142    pub closed_tx: tokio::sync::watch::Sender<bool>,
143    pub closed_rx: tokio::sync::watch::Receiver<bool>,
144    /// Number of currently active QUIC connections (incremented by serve loop,
145    /// decremented via RAII guard when each connection task exits).
146    pub active_connections: Arc<AtomicUsize>,
147    /// Number of currently in-flight HTTP requests (incremented when a
148    /// bi-stream is accepted, decremented when the request task exits).
149    pub active_requests: Arc<AtomicUsize>,
150    /// Body compression options, if the feature is enabled.
151    #[cfg(feature = "compression")]
152    pub compression: Option<CompressionOptions>,
153}
154
155impl IrohEndpoint {
156    /// Bind an Iroh endpoint with the supplied options.
157    pub async fn bind(opts: NodeOptions) -> Result<Self, crate::CoreError> {
158        // Validate: if networking is disabled, relay_mode should not be explicitly set to a
159        // network-requiring mode.
160        if opts.networking.disabled
161            && opts
162                .networking
163                .relay_mode
164                .as_deref()
165                .is_some_and(|m| !matches!(m, "disabled"))
166        {
167            return Err(crate::CoreError::invalid_input(
168                "networking.disabled is true but relay_mode is set to a non-disabled value; \
169                 set relay_mode to \"disabled\" or omit it when networking.disabled is true",
170            ));
171        }
172
173        let relay_mode = if opts.networking.disabled {
174            RelayMode::Disabled
175        } else {
176            match opts.networking.relay_mode.as_deref() {
177                None | Some("default") => RelayMode::Default,
178                Some("staging") => RelayMode::Staging,
179                Some("disabled") => RelayMode::Disabled,
180                Some("custom") => {
181                    if opts.networking.relays.is_empty() {
182                        return Err(crate::CoreError::invalid_input(
183                            "relay_mode \"custom\" requires at least one URL in `relays`",
184                        ));
185                    }
186                    let urls = opts
187                        .networking
188                        .relays
189                        .iter()
190                        .map(|u| {
191                            u.parse::<iroh::RelayUrl>()
192                                .map_err(crate::CoreError::invalid_input)
193                        })
194                        .collect::<Result<Vec<_>, _>>()?;
195                    RelayMode::custom(urls)
196                }
197                Some(other) => {
198                    return Err(crate::CoreError::invalid_input(format!(
199                        "unknown relay_mode: {other}"
200                    )))
201                }
202            }
203        };
204
205        let alpns: Vec<Vec<u8>> = if opts.capabilities.is_empty() {
206            // Advertise both ALPN variants.
207            vec![ALPN_DUPLEX.to_vec(), ALPN.to_vec()]
208        } else {
209            let mut list: Vec<Vec<u8>> = opts
210                .capabilities
211                .iter()
212                .map(|c| c.as_bytes().to_vec())
213                .collect();
214            // Always include the base protocol so the node can talk to base-only peers.
215            if !list.iter().any(|a| a == ALPN) {
216                list.push(ALPN.to_vec());
217            }
218            list
219        };
220
221        let mut builder = Endpoint::empty_builder(relay_mode).alpns(alpns);
222
223        // DNS discovery (enabled by default unless networking.disabled).
224        if !opts.networking.disabled && opts.discovery.enabled {
225            if let Some(ref url_str) = opts.discovery.dns_server {
226                let url: url::Url = url_str.parse().map_err(|e| {
227                    crate::CoreError::invalid_input(format!("invalid dns_discovery URL: {e}"))
228                })?;
229                builder = builder
230                    .address_lookup(PkarrPublisher::builder(url.clone()))
231                    .address_lookup(DnsAddressLookup::builder(
232                        url.host_str().unwrap_or_default().to_string(),
233                    ));
234            } else {
235                builder = builder
236                    .address_lookup(PkarrPublisher::n0_dns())
237                    .address_lookup(DnsAddressLookup::n0_dns());
238            }
239        }
240
241        if let Some(key_bytes) = opts.key {
242            builder = builder.secret_key(SecretKey::from_bytes(&key_bytes));
243        }
244
245        if let Some(ms) = opts.networking.idle_timeout_ms {
246            let timeout = IdleTimeout::try_from(Duration::from_millis(ms)).map_err(|e| {
247                crate::CoreError::invalid_input(format!("idle_timeout_ms out of range: {e}"))
248            })?;
249            let transport = QuicTransportConfig::builder()
250                .max_idle_timeout(Some(timeout))
251                .build();
252            builder = builder.transport_config(transport);
253        }
254
255        // Bind address(es).
256        for addr_str in &opts.networking.bind_addrs {
257            let sock: std::net::SocketAddr = addr_str.parse().map_err(|e| {
258                crate::CoreError::invalid_input(format!("invalid bind address \"{addr_str}\": {e}"))
259            })?;
260            builder = builder.bind_addr(sock).map_err(|e| {
261                crate::CoreError::invalid_input(format!("bind address \"{addr_str}\": {e}"))
262            })?;
263        }
264
265        // Proxy configuration.
266        if let Some(ref proxy) = opts.networking.proxy_url {
267            let url: url::Url = proxy
268                .parse()
269                .map_err(|e| crate::CoreError::invalid_input(format!("invalid proxy URL: {e}")))?;
270            builder = builder.proxy_url(url);
271        } else if opts.networking.proxy_from_env {
272            builder = builder.proxy_from_env();
273        }
274
275        // TLS keylog for Wireshark debugging.
276        if opts.keylog {
277            builder = builder.keylog(true);
278        }
279
280        let ep = builder.bind().await.map_err(classify_bind_error)?;
281
282        let node_id_str = crate::base32_encode(ep.id().as_bytes());
283
284        let store_config = StoreConfig {
285            channel_capacity: opts
286                .streaming
287                .channel_capacity
288                .unwrap_or(crate::stream::DEFAULT_CHANNEL_CAPACITY)
289                .max(1),
290            max_chunk_size: opts
291                .streaming
292                .max_chunk_size_bytes
293                .unwrap_or(crate::stream::DEFAULT_MAX_CHUNK_SIZE)
294                .max(1),
295            drain_timeout: Duration::from_millis(
296                opts.streaming
297                    .drain_timeout_ms
298                    .unwrap_or(crate::stream::DEFAULT_DRAIN_TIMEOUT_MS),
299            ),
300            max_handles: crate::stream::DEFAULT_MAX_HANDLES,
301            ttl: Duration::from_millis(
302                opts.streaming
303                    .handle_ttl_ms
304                    .unwrap_or(crate::stream::DEFAULT_SLAB_TTL_MS),
305            ),
306        };
307        let sweep_ttl = store_config.ttl;
308        let (closed_tx, closed_rx) = tokio::sync::watch::channel(false);
309
310        let inner = Arc::new(EndpointInner {
311            ep,
312            node_id_str,
313            pool: ConnectionPool::new(
314                opts.pool.max_connections,
315                opts.pool
316                    .idle_timeout_ms
317                    .map(std::time::Duration::from_millis),
318            ),
319            // ISS-020: treat 0 as "use default" — it would otherwise underflow
320            // the hyper minimum (ISS-001).  None also defaults to 64 KB.
321            max_header_size: match opts.max_header_size {
322                None | Some(0) => 64 * 1024,
323                Some(n) => n,
324            },
325            server_limits: {
326                let mut sl = opts.server_limits.clone();
327                if sl.max_consecutive_errors.is_none() {
328                    sl.max_consecutive_errors = Some(5);
329                }
330                sl
331            },
332            handles: HandleStore::new(store_config),
333            serve_handle: std::sync::Mutex::new(None),
334            serve_done_rx: std::sync::Mutex::new(None),
335            closed_tx,
336            closed_rx,
337            active_connections: Arc::new(AtomicUsize::new(0)),
338            active_requests: Arc::new(AtomicUsize::new(0)),
339            #[cfg(feature = "compression")]
340            compression: opts.compression,
341        });
342
343        // Start per-endpoint sweep task (held alive via Weak reference).
344        if !sweep_ttl.is_zero() {
345            let weak = Arc::downgrade(&inner);
346            tokio::spawn(async move {
347                let mut ticker = tokio::time::interval(Duration::from_secs(60));
348                loop {
349                    ticker.tick().await;
350                    let Some(inner) = weak.upgrade() else {
351                        break;
352                    };
353                    inner.handles.sweep(sweep_ttl);
354                    drop(inner); // release strong ref between ticks
355                }
356            });
357        }
358
359        Ok(Self { inner })
360    }
361
362    /// The node's public key as a lowercase base32 string.
363    pub fn node_id(&self) -> &str {
364        &self.inner.node_id_str
365    }
366
367    /// The configured consecutive-error limit for the serve loop.
368    pub fn max_consecutive_errors(&self) -> usize {
369        self.inner.server_limits.max_consecutive_errors.unwrap_or(5)
370    }
371
372    /// Build a [`ServeOptions`] from the endpoint's stored configuration.
373    ///
374    /// Platform adapters should call this instead of constructing `ServeOptions`
375    /// manually so that all server-limit fields are forwarded consistently.
376    pub fn serve_options(&self) -> crate::server::ServeOptions {
377        self.inner.server_limits.clone()
378    }
379
380    /// The node's raw secret key bytes (32 bytes).
381    pub fn secret_key_bytes(&self) -> [u8; 32] {
382        self.inner.ep.secret_key().to_bytes()
383    }
384
385    /// Graceful close: signal the serve loop to stop accepting, wait for
386    /// in-flight requests to drain (up to the configured drain timeout),
387    /// then close the QUIC endpoint.
388    ///
389    /// If no serve loop is running, closes the endpoint immediately.
390    /// The handle store (all registries) is freed when the last `IrohEndpoint`
391    /// clone is dropped — no explicit unregister is needed.
392    pub async fn close(&self) {
393        // ISS-027: drain in-flight requests *before* dropping so that request
394        // handlers can still access their reader/writer/trailer handles
395        // during the drain window.
396        let handle = self
397            .inner
398            .serve_handle
399            .lock()
400            .unwrap_or_else(|e| e.into_inner())
401            .take();
402        if let Some(h) = handle {
403            h.drain().await;
404        }
405        self.inner.ep.close().await;
406        let _ = self.inner.closed_tx.send(true);
407    }
408
409    /// Immediate close: abort the serve loop and close the endpoint with
410    /// no drain period.
411    pub async fn close_force(&self) {
412        let handle = self
413            .inner
414            .serve_handle
415            .lock()
416            .unwrap_or_else(|e| e.into_inner())
417            .take();
418        if let Some(h) = handle {
419            h.abort();
420        }
421        self.inner.ep.close().await;
422        let _ = self.inner.closed_tx.send(true);
423    }
424
425    /// Wait until this endpoint has been closed (either explicitly via `close()` /
426    /// `close_force()`, or because the native QUIC stack shut down).
427    ///
428    /// Returns immediately if already closed.
429    pub async fn wait_closed(&self) {
430        let mut rx = self.inner.closed_rx.clone();
431        let _ = rx.wait_for(|v| *v).await;
432    }
433
434    /// Store a serve handle so that `close()` can drain it.
435    pub fn set_serve_handle(&self, handle: ServeHandle) {
436        *self
437            .inner
438            .serve_done_rx
439            .lock()
440            .unwrap_or_else(|e| e.into_inner()) = Some(handle.subscribe_done());
441        *self
442            .inner
443            .serve_handle
444            .lock()
445            .unwrap_or_else(|e| e.into_inner()) = Some(handle);
446    }
447
448    /// Signal the serve loop to stop accepting new connections.
449    ///
450    /// Returns immediately — does NOT close the endpoint or drain in-flight
451    /// requests.  The handle is preserved so `close()` can still drain later.
452    pub fn stop_serve(&self) {
453        if let Some(h) = self
454            .inner
455            .serve_handle
456            .lock()
457            .unwrap_or_else(|e| e.into_inner())
458            .as_ref()
459        {
460            h.shutdown();
461        }
462    }
463
464    /// Wait until the serve loop has fully exited (serve task drained and finished).
465    ///
466    /// Returns immediately if `serve()` was never called.
467    pub async fn wait_serve_stop(&self) {
468        let rx = self
469            .inner
470            .serve_done_rx
471            .lock()
472            .unwrap_or_else(|e| e.into_inner())
473            .clone();
474        if let Some(mut rx) = rx {
475            // wait_for returns Err only if the sender is dropped; being dropped
476            // also means the task has exited, so treat both outcomes as "done".
477            let _ = rx.wait_for(|v| *v).await;
478        }
479    }
480
481    pub fn raw(&self) -> &Endpoint {
482        &self.inner.ep
483    }
484
485    /// Per-endpoint handle store.
486    pub fn handles(&self) -> &HandleStore {
487        &self.inner.handles
488    }
489
490    /// Maximum byte size of an HTTP/1.1 head.
491    pub fn max_header_size(&self) -> usize {
492        self.inner.max_header_size
493    }
494
495    /// Access the connection pool.
496    pub(crate) fn pool(&self) -> &ConnectionPool {
497        &self.inner.pool
498    }
499
500    /// Snapshot of current endpoint statistics.
501    ///
502    /// All fields are point-in-time reads and may change between calls.
503    pub fn endpoint_stats(&self) -> EndpointStats {
504        let (active_readers, active_writers, active_sessions, total_handles) =
505            self.inner.handles.count_handles();
506        let pool_size = self.inner.pool.entry_count_approx();
507        let active_connections = self.inner.active_connections.load(Ordering::Relaxed);
508        let active_requests = self.inner.active_requests.load(Ordering::Relaxed);
509        EndpointStats {
510            active_readers,
511            active_writers,
512            active_sessions,
513            total_handles,
514            pool_size,
515            active_connections,
516            active_requests,
517        }
518    }
519
520    /// Compression options, if the `compression` feature is enabled.
521    #[cfg(feature = "compression")]
522    pub fn compression(&self) -> Option<&CompressionOptions> {
523        self.inner.compression.as_ref()
524    }
525
526    /// Returns the local socket addresses this endpoint is bound to.
527    pub fn bound_sockets(&self) -> Vec<std::net::SocketAddr> {
528        self.inner.ep.bound_sockets()
529    }
530
531    /// Full node address: node ID + relay URL(s) + direct socket addresses.
532    pub fn node_addr(&self) -> NodeAddrInfo {
533        let addr = self.inner.ep.addr();
534        let mut addrs = Vec::new();
535        for relay in addr.relay_urls() {
536            addrs.push(relay.to_string());
537        }
538        for da in addr.ip_addrs() {
539            addrs.push(da.to_string());
540        }
541        NodeAddrInfo {
542            id: self.inner.node_id_str.clone(),
543            addrs,
544        }
545    }
546
547    /// Home relay URL, or `None` if not connected to a relay.
548    pub fn home_relay(&self) -> Option<String> {
549        self.inner
550            .ep
551            .addr()
552            .relay_urls()
553            .next()
554            .map(|u| u.to_string())
555    }
556
557    /// Known addresses for a remote peer, or `None` if not in the endpoint's cache.
558    pub async fn peer_info(&self, node_id_b32: &str) -> Option<NodeAddrInfo> {
559        let bytes = crate::base32_decode(node_id_b32).ok()?;
560        let arr: [u8; 32] = bytes.try_into().ok()?;
561        let pk = iroh::PublicKey::from_bytes(&arr).ok()?;
562        let info = self.inner.ep.remote_info(pk).await?;
563        let id = crate::base32_encode(info.id().as_bytes());
564        let mut addrs = Vec::new();
565        for a in info.addrs() {
566            match a.addr() {
567                iroh::TransportAddr::Ip(sock) => addrs.push(sock.to_string()),
568                iroh::TransportAddr::Relay(url) => addrs.push(url.to_string()),
569                other => addrs.push(format!("{:?}", other)),
570            }
571        }
572        Some(NodeAddrInfo { id, addrs })
573    }
574
575    /// Per-peer connection statistics.
576    ///
577    /// Returns path information for each known transport address, including
578    /// whether each path is via a relay or direct, and which is active.
579    pub async fn peer_stats(&self, node_id_b32: &str) -> Option<PeerStats> {
580        let bytes = crate::base32_decode(node_id_b32).ok()?;
581        let arr: [u8; 32] = bytes.try_into().ok()?;
582        let pk = iroh::PublicKey::from_bytes(&arr).ok()?;
583        let info = self.inner.ep.remote_info(pk).await?;
584
585        let mut paths = Vec::new();
586        let mut has_active_relay = false;
587        let mut active_relay_url: Option<String> = None;
588
589        for a in info.addrs() {
590            let is_relay = a.addr().is_relay();
591            let is_active = matches!(a.usage(), TransportAddrUsage::Active);
592
593            let addr_str = match a.addr() {
594                iroh::TransportAddr::Ip(sock) => sock.to_string(),
595                iroh::TransportAddr::Relay(url) => {
596                    if is_active {
597                        has_active_relay = true;
598                        active_relay_url = Some(url.to_string());
599                    }
600                    url.to_string()
601                }
602                other => format!("{:?}", other),
603            };
604
605            paths.push(PathInfo {
606                relay: is_relay,
607                addr: addr_str,
608                active: is_active,
609            });
610        }
611
612        // Enrich with QUIC connection-level stats if a pooled connection exists.
613        let (rtt_ms, bytes_sent, bytes_received, lost_packets, sent_packets, congestion_window) =
614            if let Some(pooled) = self.inner.pool.get_existing(pk, crate::ALPN).await {
615                let s = pooled.conn.stats();
616                let rtt = pooled.conn.rtt(iroh::endpoint::PathId::ZERO);
617                (
618                    rtt.map(|d| d.as_secs_f64() * 1000.0),
619                    Some(s.udp_tx.bytes),
620                    Some(s.udp_rx.bytes),
621                    None, // quinn path stats not exposed via iroh ConnectionStats
622                    None, // quinn path stats not exposed via iroh ConnectionStats
623                    None, // quinn path stats not exposed via iroh ConnectionStats
624                )
625            } else {
626                (None, None, None, None, None, None)
627            };
628
629        Some(PeerStats {
630            relay: has_active_relay,
631            relay_url: active_relay_url,
632            paths,
633            rtt_ms,
634            bytes_sent,
635            bytes_received,
636            lost_packets,
637            sent_packets,
638            congestion_window,
639        })
640    }
641}
642
643// ── Bind-error classification ────────────────────────────────────────────────
644
645/// Classify a bind error into a prefixed string for the JS error mapper.
646fn classify_bind_error(e: impl std::fmt::Display) -> crate::CoreError {
647    let msg = e.to_string();
648    crate::CoreError::connection_failed(msg)
649}
650
651// ── NodeAddr info ────────────────────────────────────────────────────────────
652
653/// Serialisable node address: node ID + relay and direct addresses.
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct NodeAddrInfo {
656    /// Base32-encoded public key.
657    pub id: String,
658    /// Relay URLs and/or `ip:port` direct addresses.
659    pub addrs: Vec<String>,
660}
661
662// ── Observability types ──────────────────────────────────────────────────────
663
664/// Endpoint-level observability snapshot.
665///
666/// Returned by [`IrohEndpoint::endpoint_stats`].  All counts are point-in-time reads
667/// and may change between calls.
668#[derive(Debug, Clone, Serialize, Deserialize, Default)]
669#[serde(rename_all = "camelCase")]
670pub struct EndpointStats {
671    /// Number of currently open body reader handles.
672    pub active_readers: usize,
673    /// Number of currently open body writer handles.
674    pub active_writers: usize,
675    /// Number of live QUIC sessions (WebTransport connections).
676    pub active_sessions: usize,
677    /// Total number of allocated (reader + writer + trailer + session + other) handles.
678    pub total_handles: usize,
679    /// Number of QUIC connections currently cached in the connection pool.
680    pub pool_size: u64,
681    /// Number of live QUIC connections accepted by the serve loop.
682    pub active_connections: usize,
683    /// Number of HTTP requests currently being processed.
684    pub active_requests: usize,
685}
686
687/// A connection lifecycle event fired when a QUIC peer connection opens or closes.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689#[serde(rename_all = "camelCase")]
690pub struct ConnectionEvent {
691    /// Base32-encoded public key of the peer.
692    pub peer_id: String,
693    /// `true` when this is the first connection from the peer (0→1), `false` when the last one closes (1→0).
694    pub connected: bool,
695}
696
697/// Per-peer connection statistics.
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct PeerStats {
700    /// Whether the peer is connected via a relay server (vs direct).
701    pub relay: bool,
702    /// Active relay URL, if any.
703    pub relay_url: Option<String>,
704    /// All known paths to this peer.
705    pub paths: Vec<PathInfo>,
706    /// Round-trip time in milliseconds.  `None` if no active QUIC connection is pooled.
707    pub rtt_ms: Option<f64>,
708    /// Total UDP bytes sent to this peer.  `None` if no active QUIC connection is pooled.
709    pub bytes_sent: Option<u64>,
710    /// Total UDP bytes received from this peer.  `None` if no active QUIC connection is pooled.
711    pub bytes_received: Option<u64>,
712    /// Total packets lost on the QUIC path.  `None` if no active QUIC connection is pooled.
713    pub lost_packets: Option<u64>,
714    /// Total packets sent on the QUIC path.  `None` if no active QUIC connection is pooled.
715    pub sent_packets: Option<u64>,
716    /// Current congestion window in bytes.  `None` if no active QUIC connection is pooled.
717    pub congestion_window: Option<u64>,
718}
719
720/// Network path information for a single transport address.
721#[derive(Debug, Clone, Serialize, Deserialize)]
722pub struct PathInfo {
723    /// Whether this path goes through a relay server.
724    pub relay: bool,
725    /// The relay URL (if relay) or `ip:port` (if direct).
726    pub addr: String,
727    /// Whether this is the currently selected/active path.
728    pub active: bool,
729}
730
731/// Parse an optional list of socket address strings into `SocketAddr` values.
732///
733/// Returns `Err` if any string cannot be parsed as a `host:port` address so
734/// that callers can surface misconfiguration rather than silently ignoring it.
735pub fn parse_direct_addrs(
736    addrs: &Option<Vec<String>>,
737) -> Result<Option<Vec<std::net::SocketAddr>>, String> {
738    match addrs {
739        None => Ok(None),
740        Some(v) => {
741            let mut out = Vec::with_capacity(v.len());
742            for s in v {
743                let addr = s
744                    .parse::<std::net::SocketAddr>()
745                    .map_err(|e| format!("invalid direct address {s:?}: {e}"))?;
746                out.push(addr);
747            }
748            Ok(Some(out))
749        }
750    }
751}