arcature 2026.2.1

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! The IPC endpoint where Vite listens in `middlewareMode`.
//!
//! One responsibility: represent the path to the Vite IPC server and open a
//! connection to it (engine spec ยง4: the transport boundary). On Unix this is
//! a Unix domain socket; on Windows a named pipe. Both implement
//! `AsyncRead + AsyncWrite + Send + Unpin + 'static` โ€” the exact bound
//! `hyper::client::conn::http1::handshake` requires.
//!
//! # Security
//!
//! The endpoint path is generated by `arc dev` in a process-private temp
//! location (Unix: `/tmp/arcature-vite-<pid>.sock`; Windows:
//! `\\.\pipe\arcature-vite-<pid>`). It is never attacker-controlled. The
//! socket/pipe is created with platform-default permissions (0600 on Unix);
//! Vite's `middlewareMode` listener never binds a TCP port. See the AP2.1-3
//! security review.

use std::io;
use std::path::{Path, PathBuf};

/// The IPC path where the Vite dev server listens in `middlewareMode`.
///
/// Constructed by [`IpcEndpoint::new`]; the pipeline assembler stores it and
/// the forward layer connects to it per request. `Clone + Send + Sync + 'static`
/// so it can live in the `Application` struct and the `DevProxyLayer`.
#[derive(Debug, Clone)]
pub struct IpcEndpoint {
    path: PathBuf,
}

/// The connected IPC stream type โ€” platform-specific, behind a cfg-gated
/// alias so the forward code is monomorphic and zero-overhead (no `Box<dyn
/// AsyncRead>`, no dynamic dispatch).
#[cfg(unix)]
pub(crate) type IpcStream = tokio::net::UnixStream;
#[cfg(windows)]
pub(crate) type IpcStream = tokio::net::windows::named_pipe::NamedPipeClient;

impl IpcEndpoint {
    /// Build an endpoint from the path passed by `arc dev` via
    /// `ARCATURE_VITE_IPC`.
    ///
    /// The path must be non-empty. On Unix it is a filesystem path (the socket
    /// file); on Windows it is a `\\.\pipe\...` name.
    #[must_use]
    pub fn new(path: PathBuf) -> Self {
        Self { path }
    }

    /// The raw path (for diagnostics โ€” never logged with secrets, as the IPC
    /// path carries no secret; it is process-private and per-invocation).
    #[cfg_attr(not(test), expect(dead_code))]
    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Open a fresh connection to the Vite IPC server.
    ///
    /// Called once per forwarded request. A connection-pool is deliberately
    /// avoided: the dev proxy is dev-only (not a production hot path), and
    /// WebSocket upgrades consume the connection, so per-request is the
    /// honest lifecycle.
    ///
    /// # Errors
    ///
    /// `io::Error` if the Vite IPC server is not listening (startup race,
    /// stale socket, or Vite crashed). The forward layer converts this into
    /// a 502 Bad Gateway.
    pub(crate) async fn connect(&self) -> io::Result<IpcStream> {
        #[cfg(unix)]
        {
            tokio::net::UnixStream::connect(&self.path).await
        }
        #[cfg(windows)]
        {
            tokio::net::windows::named_pipe::ClientOptions::new().open(&self.path)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn endpoint_stores_path() {
        let endpoint = IpcEndpoint::new(PathBuf::from("/tmp/arcature-vite-test.sock"));
        assert_eq!(endpoint.path(), Path::new("/tmp/arcature-vite-test.sock"));
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn connect_returns_error_when_socket_absent() {
        let endpoint = IpcEndpoint::new(PathBuf::from(
            "/tmp/arcature-vite-nonexistent-socket-test.sock",
        ));
        let result = endpoint.connect().await;
        assert!(result.is_err());
        let error = result.expect_err("connect should fail for absent socket");
        assert_eq!(
            error.kind(),
            io::ErrorKind::NotFound,
            "expected NotFound for absent Unix socket"
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn connect_succeeds_when_socket_listens() {
        let listener =
            tokio::net::UnixListener::bind("/tmp/arcature-vite-endpoint-listen-test.sock")
                .expect("bind should succeed");
        let endpoint = IpcEndpoint::new(PathBuf::from(
            "/tmp/arcature-vite-endpoint-listen-test.sock",
        ));
        let result = endpoint.connect().await;
        assert!(result.is_ok());
        drop(listener);
        let _ = std::fs::remove_file("/tmp/arcature-vite-endpoint-listen-test.sock");
    }
}