arcature 0.1.1

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
//! The Node side of the one-port topology.
//!
//! Vite runs in `middlewareMode` -- it binds no TCP port -- and its HTTP
//! server listens on the IPC endpoint the supervisor minted. The supervisor
//! forwards Vite's requests, including the HMR WebSocket upgrade, over that
//! endpoint. The browser therefore only ever talks to the supervisor's one
//! port, which is the whole point.
//!
//! # Why a generated file rather than an npm package
//!
//! The Node half of this is twenty-five lines and depends on nothing but the
//! `vite` the application already has. Publishing that as a package would
//! add a version to keep in step with the supervisor, an install step to a
//! command whose job is to start fast, and a supply-chain entry for code the
//! framework writes itself. Generating it into `.arcature/` keeps the two
//! halves in one repository and makes the file readable by whoever is
//! debugging it.
//!
//! The file is rewritten on every `arc dev`, so an edit to it does not
//! survive -- that is deliberate, since a stale copy against a newer
//! supervisor is exactly the failure a package version would have caused.

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

/// The generated entry point's filename inside `.arcature/`.
pub(crate) const SCRIPT_NAME: &str = "vite-ipc.mjs";

/// The Node entry point, written verbatim into `.arcature/vite-ipc.mjs`.
///
/// Two things in here are load-bearing and easy to get wrong:
///
/// - `hmr: { server: httpServer }` is how Vite is told to put its HMR
///   WebSocket on an `http.Server` that someone else owns. Without it Vite
///   opens its own WebSocket port and the topology quietly grows a second
///   origin.
/// - `appType: 'custom'` stops Vite from serving an `index.html` fallback.
///   The application owns HTML; Vite owns modules and assets.
///
/// The sentinel plugin is the reload half of the rebuild loop. Vite's own
/// watcher is already running, so watching one more file costs nothing, and
/// a `full-reload` is the right message: the backend changed, so the page's
/// data is stale even where its modules are not.
const SCRIPT: &str = r#"// Generated by `arc dev`. Rewritten on every run -- edits do not survive.
//
// Vite in middlewareMode listening on an IPC endpoint: no TCP port, no
// second origin. The Rust supervisor owns the only port and forwards here.
import { createServer } from 'vite'
import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'

const endpoint = process.env.ARCATURE_VITE_IPC
if (!endpoint) {
  console.error('arc dev: ARCATURE_VITE_IPC is not set; nothing to listen on')
  process.exit(1)
}

// A socket file left by a killed run makes listen() fail with EADDRINUSE.
if (process.platform !== 'win32' && fs.existsSync(endpoint)) fs.unlinkSync(endpoint)

const httpServer = http.createServer()
const sentinel = path.resolve(process.env.ARCATURE_RESTART_SENTINEL ?? '.arcature/restart')

// The backend just came back up, so the page's data is stale even where its
// modules are not. Vite's watcher is already running; one more file is free.
const reloadWhenBackendRestarts = {
  name: 'arcature:restart-reload',
  configureServer(server) {
    // One unwatchable file is not a reason to stop serving, and an
    // unhandled 'error' on an EventEmitter is a process-level throw -- so
    // without this line a single locked file kills the dev server outright.
    server.watcher.on('error', (error) => {
      console.error(`arc dev: vite watcher: ${error.message}`)
    })
    server.watcher.add(sentinel)
    server.watcher.on('change', (file) => {
      if (path.resolve(file) !== sentinel) return
      const hot = server.hot ?? server.ws
      hot.send({ type: 'full-reload' })
    })
  },
}

const vite = await createServer({
  server: {
    middlewareMode: true,
    hmr: { server: httpServer },
    // Vite watches the project root, and the root of a Rust project holds
    // `target/`: gigabytes of build output, one file of which is the
    // executable being relinked at this moment. On Windows watching that
    // file fails with EBUSY; on Linux it is one inotify watch per directory
    // against a default limit of 8192. No source file lives down there, so
    // the whole tree is excluded rather than tuned.
    watch: { ignored: ['**/target/**'] },
  },
  appType: 'custom',
  plugins: [reloadWhenBackendRestarts],
})

httpServer.on('request', vite.middlewares)
httpServer.listen(endpoint, () => {
  console.log(`arc dev: vite listening on ${endpoint}`)
})

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

// The supervisor holds this pipe open and never writes to it. EOF therefore
// means the supervisor is gone -- including the ways it can go without
// running any cleanup of its own -- and an orphaned Vite would keep a
// recursive file watcher and an IPC endpoint alive for as long as the
// terminal does.
process.stdin.on('end', shutdown)
process.stdin.on('close', shutdown)
process.stdin.resume()
"#;

/// Write the Vite entry point into `scratch`, returning its path.
///
/// # Errors
///
/// `io::Error` if the file cannot be written.
pub(crate) fn write_script(scratch: &Path) -> std::io::Result<PathBuf> {
    let path = scratch.join(SCRIPT_NAME);
    std::fs::write(&path, SCRIPT)?;
    Ok(path)
}

/// The script's text, for the tests that assert on what it contains.
#[cfg(test)]
pub(crate) fn script_text() -> &'static str {
    SCRIPT
}

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

    #[test]
    fn the_script_puts_vite_in_middleware_mode_with_no_port() {
        let script = script_text();
        assert!(script.contains("middlewareMode: true"), "{script}");
        assert!(
            !script.contains("port:"),
            "the script must never ask Vite for a TCP port"
        );
        assert!(
            !script.contains("strictPort"),
            "a port option at all means the topology grew a second origin"
        );
    }

    #[test]
    fn the_script_hands_the_hmr_websocket_to_the_ipc_server() {
        // Without this, Vite opens a WebSocket listener of its own and the
        // browser talks to two origins.
        assert!(script_text().contains("hmr: { server: httpServer }"));
    }

    #[test]
    fn the_script_listens_on_the_endpoint_the_supervisor_minted() {
        let script = script_text();
        assert!(script.contains("process.env.ARCATURE_VITE_IPC"));
        assert!(script.contains("httpServer.listen(endpoint"));
    }

    #[test]
    fn the_script_keeps_vites_watcher_out_of_the_rust_build_directory() {
        // `target/` is where the executable being relinked lives. Watching it
        // is EBUSY on Windows and inotify exhaustion on Linux, and nothing
        // Vite compiles is in there.
        let script = script_text();
        assert!(
            script.contains("watch: { ignored: ['**/target/**'] }"),
            "{script}"
        );
        assert!(
            script.contains("server.watcher.on('error'"),
            "an unhandled watcher error takes the whole dev server down with it"
        );
    }

    #[test]
    fn the_script_exits_when_the_supervisor_stops_holding_its_stdin_open() {
        // Without this, `kill -9` on the supervisor leaves Vite watching the
        // project forever and holding the endpoint the next run needs.
        let script = script_text();
        assert!(
            script.contains("process.stdin.on('end', shutdown)"),
            "{script}"
        );
        assert!(
            script.contains("process.stdin.resume()"),
            "a paused stdin never emits `end`, so the watch would never fire"
        );
    }

    #[test]
    fn the_script_reloads_the_browser_when_the_sentinel_changes() {
        let script = script_text();
        assert!(script.contains("full-reload"));
        assert!(script.contains("server.watcher.add(sentinel)"));
    }

    #[test]
    fn the_script_imports_nothing_that_is_not_already_installed() {
        // `vite` is the application's own dependency; the rest is Node's
        // standard library. Anything else would be an install step on the
        // critical path of a command whose job is to start fast.
        let imports: Vec<&str> = script_text()
            .lines()
            .filter(|line| line.starts_with("import "))
            .collect();
        for import in &imports {
            assert!(
                import.ends_with("from 'vite'") || import.contains("from 'node:"),
                "unexpected dependency: {import}"
            );
        }
        // `vite`, `node:http`, `node:fs`, `node:path`. The count is asserted
        // so that adding a dependency is a deliberate edit to this test and
        // not something that arrives unnoticed with a feature.
        assert_eq!(imports.len(), 4, "{imports:?}");
        // `require` would slip past the `import` filter above and pull in the
        // same install step by another spelling.
        assert!(
            !script_text().contains("require("),
            "the script uses require"
        );
    }

    #[test]
    fn writing_the_script_puts_it_in_the_scratch_directory() {
        let dir = std::env::temp_dir().join(format!("arcature-vite-script-{}", std::process::id()));
        std::fs::create_dir_all(&dir).expect("temp dir");
        let path = write_script(&dir).expect("script should be writable");
        assert_eq!(path, dir.join(SCRIPT_NAME));
        assert_eq!(
            std::fs::read_to_string(&path).expect("read back"),
            script_text()
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}