arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! The Vite IPC harness — start Vite in `middlewareMode` over IPC.
//!
//! One responsibility: launch a Vite dev server that owns **no TCP port**.
//! Instead of binding a TCP listener, Vite's `middlewareMode` server speaks
//! HTTP/1 over an IPC endpoint (Unix socket / Windows named pipe). The Rust
//! application owns the single TCP listener and forwards Vite-looking
//! requests to this IPC endpoint via the `dev-proxy` layer.
//!
//! # The harness script
//!
//! [`VITE_IPC_HARNESS`] is a small Node ESM script (~20 lines) that:
//! 1. Creates an `http.Server` (no port bound).
//! 2. Calls Vite `createServer({ server: { middlewareMode: true, ws: {
//!    server: httpServer } }, appType: 'custom' })`.
//! 3. Attaches `vite.middlewares` to the HTTP server.
//! 4. Listens on the IPC path from `process.env.ARCATURE_VITE_IPC`.
//! 5. Handles `SIGTERM`/`SIGINT` for clean `vite.close()`.
//!
//! The harness is written to `frontend_root/.arcature/vite-ipc-harness.mjs`
//! so that `import 'vite'` resolves from `frontend_root/node_modules` (ESM
//! module resolution walks up from the file's directory). The file is
//! cleaned up on shutdown. The `.arcature/` directory is a dev-only artifact.
//!
//! # HMR WebSocket
//!
//! Setting `server.ws.server = httpServer` makes Vite's HMR WebSocket ride
//! the same IPC connection. `__HMR_PORT__` stays `null`, so the Vite client
//! auto-derives the WebSocket host/port from `import.meta.url` — the page
//! origin (the single TCP listener). The dev proxy tunnels the WebSocket
//! upgrade to Vite over IPC. No second TCP port, same-origin HMR.
//!
//! # Security
//!
//! The IPC path is process-private (temp dir / named pipe namespace) and
//! per-invocation (PID-suffixed). It is never attacker-controlled. See the
//! AP2.1-3 security review.

use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;

use crate::project::ProjectConfig;

use super::{ChildGuard, ProcessError, ProcessSpec, spawn};

/// The Node ESM harness script.
///
/// Runs Vite in `middlewareMode` over the IPC path from
/// `process.env.ARCATURE_VITE_IPC`. Vite resolves from the frontend
/// `node_modules` (the harness file lives under `frontend_root/.arcature/`).
const VITE_IPC_HARNESS: &str = r#"import http from 'node:http';
import { createServer } from 'vite';

const ipcPath = process.env.ARCATURE_VITE_IPC;
if (!ipcPath) { console.error('ARCATURE_VITE_IPC is not set'); process.exit(1); }

const httpServer = http.createServer();
const vite = await createServer({
  server: { middlewareMode: true, ws: { server: httpServer } },
  appType: 'custom',
  logLevel: 'info',
});

httpServer.on('request', (req, res) => {
  vite.middlewares(req, res, () => { res.statusCode = 404; res.end('not found'); });
});

httpServer.listen(ipcPath, () => console.log(`vite-ipc ready on ${ipcPath}`));

const shutdown = async () => { await vite.close(); httpServer.close(); process.exit(0); };
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
"#;

/// Generate a process-private IPC path for the Vite server.
///
/// Unix: `<temp_dir>/arcature-vite-<pid>.sock`
/// Windows: `\\.\pipe\arcature-vite-<pid>`
///
/// The PID suffix guarantees uniqueness across concurrent `arc dev`
/// invocations. The path is never attacker-controlled (AGENTS.md §21).
#[must_use]
pub(crate) fn ipc_path() -> PathBuf {
    let pid = std::process::id();
    #[cfg(unix)]
    {
        std::env::temp_dir().join(format!("arcature-vite-{pid}.sock"))
    }
    #[cfg(windows)]
    {
        PathBuf::from(format!(r"\\.\pipe\arcature-vite-{pid}"))
    }
}

/// Start Vite in `middlewareMode` over IPC.
///
/// Writes the harness script to `frontend_root/.arcature/vite-ipc-harness.mjs`
/// and spawns `node <harness>` with `cwd = frontend_root` and
/// `ARCATURE_VITE_IPC = <ipc_path>`. Returns the child guard and the harness
/// file path (the caller deletes the file on shutdown).
///
/// # Errors
///
/// `ProcessError::Spawn` if the harness file cannot be written or the node
/// process cannot be spawned.
pub(crate) fn start_vite_ipc(
    project: &ProjectConfig,
    ipc_path: &Path,
) -> Result<(ChildGuard, PathBuf), ProcessError> {
    let frontend_root = project.frontend_root();
    let harness_dir = frontend_root.join(".arcature");
    std::fs::create_dir_all(&harness_dir).map_err(|source| ProcessError::Spawn {
        program: "node".to_owned(),
        directory: harness_dir.clone(),
        source,
    })?;
    let harness = harness_dir.join("vite-ipc-harness.mjs");
    std::fs::write(&harness, VITE_IPC_HARNESS).map_err(|source| ProcessError::Spawn {
        program: "node".to_owned(),
        directory: harness_dir,
        source,
    })?;

    let child = spawn(
        &ProcessSpec::new("node", frontend_root)
            .arg(harness.as_os_str().to_os_string())
            .env("ARCATURE_VITE_IPC", ipc_path.display().to_string())
            .new_process_group(true),
    )
    .map(|child| ChildGuard::new(child, "vite-ipc"))?;

    Ok((child, harness))
}

/// Wait for the IPC endpoint to become ready.
///
/// Unix: poll until the socket file exists (Vite's `listen()` creates it).
/// Windows: poll until the named pipe is connectable.
///
/// The timeout matches [`super::supervise`] readiness (10s). If the Vite
/// process exits before the endpoint is ready, returns `ProcessError::Failed`.
///
/// # Errors
///
/// `ProcessError::Failed` if the Vite process exits before the endpoint is
/// ready. `ProcessError::Readiness` if the endpoint does not become ready
/// before the startup timeout.
pub(crate) fn wait_ipc_ready(
    ipc_path: &Path,
    child: &mut ChildGuard,
    stopping: &AtomicBool,
) -> Result<(), ProcessError> {
    for _ in 0..100 {
        if stopping.load(Ordering::SeqCst) {
            return Ok(());
        }
        #[cfg(unix)]
        if ipc_path.exists() {
            return Ok(());
        }
        #[cfg(windows)]
        if std::fs::File::open(ipc_path).is_ok() {
            return Ok(());
        }
        if let Some(status) = child.try_wait()? {
            if stopping.load(Ordering::SeqCst) {
                return Ok(());
            }
            return Err(ProcessError::Failed {
                program: "node (Vite)".to_owned(),
                directory: PathBuf::from("."),
                status,
            });
        }
        thread::sleep(Duration::from_millis(100));
    }
    Err(ProcessError::Readiness {
        service: "vite-ipc",
        address: ipc_path.display().to_string(),
    })
}

/// Clean up the IPC endpoint and harness file after shutdown.
///
/// Unix: unlink the socket file. Windows: no-op (named pipes auto-clean).
/// The harness `.mjs` file is always removed (it is a dev-only artifact).
pub(crate) fn cleanup(ipc_path: &Path, harness: &Path) {
    #[cfg(unix)]
    {
        let _ = std::fs::remove_file(ipc_path);
    }
    #[cfg(windows)]
    {
        let _ = ipc_path;
    }
    let _ = std::fs::remove_file(harness);
    let _ = std::fs::remove_dir(harness.parent().unwrap_or(Path::new("")));
}