oj_server 0.2.16

Dev server: on-demand compile over HTTP, WebSocket HMR channel, file watcher
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Raphael Amorim

//! Optimizer quarantine: pre-seed Vite's per-environment deps caches in a
//! one-shot child (rolldown's napi build retains native memory until process
//! exit) so the plugin host loads a hash-identical cache and skips its own pass.

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

pub(crate) const OPTIMIZE_ENV_JS: &str = include_str!("assets/optimize-env.mjs");

const STAMP_FILE: &str = "optimize-env-stamp.json";
const REPORT_FILE: &str = "optimize-env-report.json";

/// Everything that invalidates a stamp besides the app's own inputs: the oj
/// version and the pre-seed script itself.
fn stamp_key() -> String {
    format!(
        "{}:{}",
        env!("CARGO_PKG_VERSION"),
        blake3::hash(OPTIMIZE_ENV_JS.as_bytes()).to_hex()
    )
}

/// Manager-identifying lockfiles in Vite's lockfileFormats order: (path, manager,
/// representative version). Most specific first: bun before yarn (a bun install
/// can also emit yarn.lock, never the reverse); rush is pnpm; yarn splits classic
/// vs berry. Shared with the npm_config_user_agent preset so this mapping and the
/// change-stamp below can never drift apart silently.
pub const PACKAGE_MANAGER_LOCKFILES: &[(&str, &str, &str)] = &[
    ("node_modules/.pnpm/lock.yaml", "pnpm", "9.0.0"),
    ("pnpm-lock.yaml", "pnpm", "9.0.0"),
    (".rush/temp/shrinkwrap-deps.json", "pnpm", "9.0.0"),
    ("bun.lock", "bun", "1.2.0"),
    ("bun.lockb", "bun", "1.2.0"),
    ("node_modules/.yarn-state.yml", "yarn", "4.0.0"),
    ("yarn.lock", "yarn", "1.22.22"),
    ("node_modules/.package-lock.json", "npm", "10.0.0"),
    ("package-lock.json", "npm", "10.0.0"),
];

/// "Dependency tree changed" markers, a deliberate superset of Vite's lookup:
/// the stamp is only compared to itself, so extra sensitivity at worst re-runs the child.
const LOCKFILE_CANDIDATES: &[&str] = &[
    "node_modules/.pnpm/lock.yaml",
    "node_modules/.package-lock.json",
    "node_modules/.yarn-state.yml",
    "node_modules/.yarn-integrity",
    "package-lock.json",
    "pnpm-lock.yaml",
    "yarn.lock",
    "bun.lock",
    "bun.lockb",
    "deno.lock",
    ".pnp.cjs",
    ".pnp.js",
    ".rush/temp/shrinkwrap-deps.json",
];

/// A digest of the dependency-tree manifests, Vite-style lookup: walk up from
/// the root, stop at the first directory holding any candidate, hash them all.
fn lockfile_stamp(root: &Path) -> String {
    let mut dir = Some(root);
    while let Some(d) = dir {
        let mut hasher = blake3::Hasher::new();
        let mut found = false;
        for name in LOCKFILE_CANDIDATES {
            let p = d.join(name);
            if let Ok(bytes) = std::fs::read(&p) {
                found = true;
                hasher.update(name.as_bytes());
                hasher.update(&[0]);
                hasher.update(&bytes);
                hasher.update(&[0]);
            }
        }
        if found {
            return hasher.finalize().to_hex().to_string();
        }
        dir = d.parent();
    }
    "none".to_string()
}

fn file_digest(path: &Path) -> Option<String> {
    std::fs::read(path)
        .ok()
        .map(|b| blake3::hash(&b).to_hex().to_string())
}

/// One environment the child seeded: where Vite committed its metadata.
#[derive(Debug)]
pub(crate) struct SeededEnv {
    name: String,
    metadata_path: PathBuf,
}

fn stamp_path(root: &Path) -> PathBuf {
    oj_cache::cache_root(root).join(STAMP_FILE)
}

/// Whether the last seed covers this boot: same oj/script, same dependency tree,
/// every seeded metadata untouched (an in-host rewrite reads cold and self-heals next boot).
pub(crate) fn stamp_is_warm(root: &Path) -> bool {
    let Ok(raw) = std::fs::read_to_string(stamp_path(root)) else {
        return false;
    };
    let Ok(stamp) = serde_json::from_str::<serde_json::Value>(&raw) else {
        return false;
    };
    if stamp.get("key").and_then(|v| v.as_str()) != Some(stamp_key().as_str()) {
        return false;
    }
    if stamp.get("lockstamp").and_then(|v| v.as_str()) != Some(lockfile_stamp(root).as_str()) {
        return false;
    }
    let Some(envs) = stamp.get("envs").and_then(|v| v.as_array()) else {
        return false;
    };
    envs.iter().all(|e| {
        let path = e.get("metadataPath").and_then(|v| v.as_str());
        let hash = e.get("hash").and_then(|v| v.as_str());
        match (path, hash) {
            (Some(p), Some(h)) => file_digest(Path::new(p)).as_deref() == Some(h),
            _ => false,
        }
    })
}

pub(crate) fn write_stamp(root: &Path, seeded: &[SeededEnv]) {
    let mut envs = Vec::new();
    for s in seeded {
        let Some(hash) = file_digest(&s.metadata_path) else {
            // A metadata file that cannot be read back must not be stamped
            // warm; leaving the stamp absent re-runs the child next boot.
            return;
        };
        envs.push(serde_json::json!({
            "name": s.name,
            "metadataPath": s.metadata_path.to_string_lossy(),
            "hash": hash,
        }));
    }
    let stamp = serde_json::json!({
        "key": stamp_key(),
        "lockstamp": lockfile_stamp(root),
        "envs": envs,
    });
    let dir = oj_cache::cache_root(root);
    let _ = std::fs::create_dir_all(&dir);
    let tmp = dir.join(format!("{STAMP_FILE}.{}.tmp", std::process::id()));
    if std::fs::write(&tmp, stamp.to_string()).is_ok() {
        let _ = std::fs::rename(&tmp, stamp_path(root));
    }
}

fn parse_report(raw: &str) -> Option<Vec<SeededEnv>> {
    let v: serde_json::Value = serde_json::from_str(raw).ok()?;
    if v.get("failed").and_then(|f| f.as_bool()) == Some(true) {
        return None;
    }
    let seeded = v.get("seeded")?.as_array()?;
    let mut out = Vec::new();
    for s in seeded {
        out.push(SeededEnv {
            name: s.get("name")?.as_str()?.to_string(),
            metadata_path: PathBuf::from(s.get("metadataPath")?.as_str()?),
        });
    }
    Some(out)
}

fn preseed_timeout() -> std::time::Duration {
    let secs = oj_env::get().knobs.preseed_timeout_secs.unwrap_or(300);
    std::time::Duration::from_secs(secs)
}

async fn run_child(root: &Path, env_mode: &str) -> anyhow::Result<Vec<SeededEnv>> {
    let cache = oj_cache::cache_root(root);
    std::fs::create_dir_all(&cache)?;
    // Atomic rename: an engine could import the script while a concurrent oj
    // process rewrites it.
    crate::plugins::ensure_asset(&cache, "optimize-env.mjs", OPTIMIZE_ENV_JS)?;
    let script = cache.join("optimize-env.mjs");
    crate::plugins::ensure_asset(
        &cache,
        "discovered-deps.mjs",
        crate::plugins::DISCOVERED_DEPS_JS,
    )?;
    let report = cache.join(REPORT_FILE);
    let _ = std::fs::remove_file(&report);
    let exe = std::env::current_exe()?;
    // One-shot `oj start-script` child: its exit releases rolldown's native
    // retention. Env pairs travel on stdin (argv would print values in `ps`).
    let mut child = tokio::process::Command::new(exe)
        .arg("start-script")
        .arg(&script)
        .arg("--root")
        .arg(root)
        .current_dir(root)
        .env(
            crate::plugins::PARENT_PID_ENV,
            std::process::id().to_string(),
        )
        .stdin(std::process::Stdio::piped())
        .kill_on_drop(true)
        .spawn()?;
    {
        use tokio::io::AsyncWriteExt;
        let env: Vec<(String, String)> = vec![
            ("OJ_APP_ROOT".into(), root.to_string_lossy().into_owned()),
            ("OJ_ENV_MODE".into(), env_mode.to_string()),
            (
                "OJ_PRESEED_REPORT".into(),
                report.to_string_lossy().into_owned(),
            ),
            // Include-extension snapshot (lib.rs preseedIncludePath): the child
            // writes it; both it and the host fold it into optimizeDeps.include.
            (
                "OJ_PRESEED_INCLUDE".into(),
                oj_cache::cache_root(root)
                    .join("preseed-include.json")
                    .to_string_lossy()
                    .into_owned(),
            ),
        ];
        let mut stdin = child.stdin.take().expect("piped stdin");
        stdin
            .write_all(serde_json::to_string(&env)?.as_bytes())
            .await?;
        // Dropping closes the pipe; the child's read_to_string completes.
    }
    let status = match tokio::time::timeout(preseed_timeout(), child.wait()).await {
        Ok(status) => status?,
        Err(_) => {
            let _ = child.kill().await;
            anyhow::bail!(
                "did not finish within {}s (raise OJ_PRESEED_TIMEOUT for slower machines)",
                preseed_timeout().as_secs()
            );
        }
    };
    if !status.success() {
        anyhow::bail!("child exited with {status}");
    }
    let raw = std::fs::read_to_string(&report)
        .map_err(|e| anyhow::anyhow!("child wrote no report: {e}"))?;
    parse_report(&raw).ok_or_else(|| anyhow::anyhow!("child reported an incomplete seed"))
}

/// Pre-seed the deps caches for runner-backed vite configs, before the host
/// spawns. Failure is never fatal: the host's own optimizer remains the fallback.
pub(crate) async fn preseed_server_deps(root: &Path, env_mode: &str) {
    if oj_env::get().knobs.no_deps_preseed {
        return;
    }
    // A fresh config extraction means the config inputs changed (or were never
    // seen): Vite's configHash may have moved, which the stamp cannot see.
    if !crate::plugins::extraction_ran_fresh() && stamp_is_warm(root) {
        crate::boot_phase("deps preseed skipped (warm)");
        return;
    }
    crate::boot_phase("deps preseed begin");
    println!("  deps: pre-optimizing server environments in a one-shot child");
    match run_child(root, env_mode).await {
        Ok(seeded) => {
            write_stamp(root, &seeded);
            crate::boot_phase("deps preseed done");
        }
        Err(e) => {
            eprintln!(
                "oj: deps pre-optimization incomplete ({e}); the plugin host optimizes in-process this session"
            );
            crate::boot_phase("deps preseed failed");
        }
    }
}

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

    // No stamp, a bad key, a moved lockfile, or touched metadata each read as
    // cold; cold is always safe, so these pin the sensitivity.
    #[test]
    fn stamp_warm_requires_key_lockstamp_and_metadata() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::write(root.join("package-lock.json"), b"{\"v\":1}").unwrap();
        let deps = root.join("node_modules/.vite/deps_ssr");
        std::fs::create_dir_all(&deps).unwrap();
        let meta = deps.join("_metadata.json");
        std::fs::write(&meta, b"{\"hash\":\"abc\"}").unwrap();

        // No stamp: cold.
        assert!(!stamp_is_warm(root));

        write_stamp(
            root,
            &[SeededEnv {
                name: "ssr".into(),
                metadata_path: meta.clone(),
            }],
        );
        assert!(stamp_is_warm(root));

        // The dependency tree changed: cold.
        std::fs::write(root.join("package-lock.json"), b"{\"v\":2}").unwrap();
        assert!(!stamp_is_warm(root));
        std::fs::write(root.join("package-lock.json"), b"{\"v\":1}").unwrap();
        assert!(stamp_is_warm(root));

        // The seeded metadata was rewritten (an in-host or foreign
        // re-optimization): cold, so the next boot re-seeds and self-heals.
        std::fs::write(&meta, b"{\"hash\":\"other\"}").unwrap();
        assert!(!stamp_is_warm(root));

        // The metadata is gone entirely (rm -rf node_modules/.vite): cold.
        std::fs::remove_file(&meta).unwrap();
        assert!(!stamp_is_warm(root));
    }

    // An empty seed (no environment optimizes) is still a valid warm stamp:
    // without it, such apps would pay the child's config resolve every boot.
    #[test]
    fn empty_seed_stamps_warm_until_lockfile_moves() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::write(root.join("yarn.lock"), b"a").unwrap();
        write_stamp(root, &[]);
        assert!(stamp_is_warm(root));
        std::fs::write(root.join("yarn.lock"), b"b").unwrap();
        assert!(!stamp_is_warm(root));
    }

    // A stamp written by another oj version (or another copy of the script)
    // never serves: the seed's semantics may have changed with it.
    #[test]
    fn stamp_key_mismatch_is_cold() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        write_stamp(root, &[]);
        let p = stamp_path(root);
        let mut v: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap();
        v["key"] = serde_json::json!("other-version:hash");
        std::fs::write(&p, v.to_string()).unwrap();
        assert!(!stamp_is_warm(root));
    }

    // The lockfile lookup walks up like Vite's, so a workspace member whose
    // lockfile lives at the monorepo root is still change-sensitive.
    #[test]
    fn lockfile_stamp_walks_up_and_tracks_content() {
        let dir = tempfile::tempdir().unwrap();
        let ws = dir.path();
        let app = ws.join("apps/web");
        std::fs::create_dir_all(&app).unwrap();
        std::fs::write(ws.join("pnpm-lock.yaml"), b"one").unwrap();
        let a = lockfile_stamp(&app);
        assert_ne!(a, "none");
        std::fs::write(ws.join("pnpm-lock.yaml"), b"two").unwrap();
        let b = lockfile_stamp(&app);
        assert_ne!(a, b);
        // No manifest anywhere: a stable "none", not an error.
        let bare = tempfile::tempdir().unwrap();
        assert_eq!(lockfile_stamp(bare.path()), "none");
    }

    // A failed or malformed report yields no seed list, so no stamp is written
    // and the next boot stays cold.
    #[test]
    fn report_parsing_rejects_failed_or_malformed() {
        assert!(parse_report("{\"seeded\":[],\"failed\":true}").is_none());
        assert!(parse_report("not json").is_none());
        assert!(parse_report("{\"seeded\":[{\"name\":\"ssr\"}],\"failed\":false}").is_none());
        let ok = parse_report(
            "{\"seeded\":[{\"name\":\"ssr\",\"metadataPath\":\"/x/_metadata.json\"}],\"failed\":false}",
        )
        .unwrap();
        assert_eq!(ok.len(), 1);
        assert_eq!(ok[0].name, "ssr");
    }
}