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