use std::path::{Path, PathBuf};
pub(crate) const SCRIPT_NAME: &str = "vite-ipc.mjs";
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()
"#;
pub(crate) fn write_script(scratch: &Path) -> std::io::Result<PathBuf> {
let path = scratch.join(SCRIPT_NAME);
std::fs::write(&path, SCRIPT)?;
Ok(path)
}
#[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() {
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() {
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() {
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() {
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}"
);
}
assert_eq!(imports.len(), 4, "{imports:?}");
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);
}
}