adaptive-oj-server 0.2.23

Dev server: on-demand compile over HTTP, WebSocket HMR channel, file watcher
use super::*;

pub(crate) fn js(body: impl IntoResponse) -> Response {
    ([(header::CONTENT_TYPE, "text/javascript")], body).into_response()
}

pub(crate) async fn serve_client_js(
    State(state): State<Arc<ServerState>>,
    headers: HeaderMap,
) -> Response {
    // Browsers refetch /@oj/client.js on every reload; the body is fixed for
    // the server's lifetime, so a matching validator saves the transfer.
    cached_js_response(
        &headers,
        state.client_js_etag.clone(),
        state.client_js.clone(),
    )
}

/// A cached, immutable-for-this-process JS body: 304 on a matching validator, else the
/// bytes verbatim (the header tuple overwrites the octet-stream type `Bytes` would set).
pub(crate) fn cached_js_response(headers: &HeaderMap, etag: String, body: Bytes) -> Response {
    if headers
        .get(header::IF_NONE_MATCH)
        .and_then(|v| v.to_str().ok())
        == Some(etag.as_str())
    {
        return (
            StatusCode::NOT_MODIFIED,
            [
                (header::ETAG, etag),
                (header::CACHE_CONTROL, "no-cache".to_string()),
            ],
        )
            .into_response();
    }
    (
        [
            (header::CONTENT_TYPE, "text/javascript".to_string()),
            (header::CACHE_CONTROL, "no-cache".to_string()),
            (header::ETAG, etag),
        ],
        body,
    )
        .into_response()
}

/// The path the HMR socket is served at: `server.hmr.path` (made absolute) or
/// oj's `/__ws`.
pub(crate) fn hmr_socket_path(hmr: Option<&oj_config::HmrOptions>) -> String {
    match hmr
        .and_then(|h| h.path.as_deref())
        .map(str::trim)
        .filter(|p| !p.is_empty())
    {
        Some(p) if p.starts_with('/') => p.to_string(),
        Some(p) => format!("/{p}"),
        None => "/__ws".to_string(),
    }
}

/// Fill the client's `__HMR_*__` / `__WS_TOKEN__` placeholders (Vite's clientInjections):
/// JSON literals, `null` where the config is silent so the client uses the page's location.
pub(crate) fn render_client_js(
    template: &str,
    hmr: Option<&oj_config::HmrOptions>,
    ws_path: &str,
    token: &str,
    forward_console: &serde_json::Value,
) -> String {
    let lit = |v: serde_json::Value| v.to_string();
    let protocol = hmr.and_then(|h| h.protocol.clone());
    let hostname = hmr.and_then(|h| h.host.clone());
    // Vite: `ws.clientPort -> ws.port -> the page's port`; oj's socket shares the
    // dev server port, so only clientPort (browser-facing, behind a proxy) moves the dial.
    let port = hmr.and_then(|h| h.client_port);
    let overlay = hmr.and_then(|h| h.overlay).unwrap_or(true);
    template
        .replace("__HMR_PROTOCOL__", &lit(protocol.into()))
        .replace("__HMR_HOSTNAME__", &lit(hostname.into()))
        .replace("__HMR_PORT__", &lit(port.into()))
        .replace("__HMR_PATH__", &lit(ws_path.into()))
        .replace("__HMR_ENABLE_OVERLAY__", &lit(overlay.into()))
        .replace("__WS_TOKEN__", &lit(token.into()))
        .replace("__FORWARD_CONSOLE__", &forward_console.to_string())
}

/// Vite 8's `server.forwardConsole` resolution (resolveForwardConsoleOptions):
/// the config value wins, then the OJ_FORWARD_CONSOLE env override, and unset
/// it defaults on only under an AI-agent environment (Vite's determineAgent;
/// oj also counts its editor-driven gate, OJ_HMR_GATE / LOVABLE_DEV_SERVER).
pub(crate) fn resolve_forward_console(
    config: Option<&serde_json::Value>,
    knobs: &oj_env::Knobs,
) -> serde_json::Value {
    let setting = config
        .cloned()
        .or(knobs.forward_console.map(serde_json::Value::Bool))
        .unwrap_or(serde_json::Value::Bool(knobs.agent_env || knobs.hmr_gate));
    match setting {
        serde_json::Value::Bool(true) => serde_json::json!({
            "enabled": true, "unhandledErrors": true, "logLevels": ["error", "warn"],
        }),
        v if v.is_object() => {
            let unhandled = v
                .get("unhandledErrors")
                .and_then(|b| b.as_bool())
                .unwrap_or(true);
            let levels = v
                .get("logLevels")
                .filter(|l| l.is_array())
                .cloned()
                .unwrap_or_else(|| serde_json::json!([]));
            let named = levels.as_array().map(Vec::len).unwrap_or(0);
            serde_json::json!({
                "enabled": unhandled || named > 0,
                "unhandledErrors": unhandled,
                "logLevels": levels,
            })
        }
        _ => serde_json::json!({
            "enabled": false, "unhandledErrors": false, "logLevels": [],
        }),
    }
}

/// A fresh random token for this process (Vite: `crypto.randomBytes(9)` base64url).
/// rustls' provider RNG, with a hash of process-unique state as the fallback.
pub(crate) fn new_ws_token() -> String {
    let mut bytes = [0u8; 16];
    let filled = rustls::crypto::aws_lc_rs::default_provider()
        .secure_random
        .fill(&mut bytes)
        .is_ok();
    if !filled {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        let seed = format!("{}:{nanos}:{:p}", std::process::id(), &bytes);
        bytes.copy_from_slice(&blake3::hash(seed.as_bytes()).as_bytes()[..16]);
    }
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Vite's ws `shouldHandle`: a request carrying `Origin` (a browser) must present the
/// token; Origin-less clients could already make plain HTTP requests, so they pass.
pub(crate) fn ws_token_rejected(
    check: bool,
    token: &str,
    headers: &HeaderMap,
    query: Option<&str>,
) -> bool {
    if !check || !headers.contains_key(header::ORIGIN) {
        return false;
    }
    !query.is_some_and(|q| {
        q.split('&')
            .any(|kv| kv.strip_prefix("token=") == Some(token))
    })
}

// An SSR module carries its source map inline: the runner maps stack frames
// through it back to the original file (Vite's ssrFixStacktrace).
pub(crate) fn with_inline_map(code: String, map_json: Option<String>) -> String {
    match map_json {
        Some(map) => format!(
            "{code}\n//# sourceMappingURL={}\n",
            oj_compiler::map_json_to_data_url(&map)
        ),
        None => code,
    }
}