alder-runtime 0.3.0

Embedded V8 runtime for standalone Alder programs
Documentation
//! A small, capability-oriented host around `deno_core`.

use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};

use deno_core::{JsRuntime, ModuleCodeString, ModuleSpecifier, OpState, RuntimeOptions, op2};
use deno_permissions::{PermissionsContainer, RuntimePermissionDescriptorParser};

#[derive(Default)]
struct HostState {
    args: Vec<String>,
    exit_code: i32,
    test_reporter: Option<Box<dyn Fn(TestEvent)>>,
}

/// Results from the test runner, separate from the program's console streams.
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum TestEvent {
    Passed {
        module: String,
        name: String,
    },
    Failed {
        module: String,
        name: String,
        message: String,
    },
    Finished {
        passed: usize,
        failed: usize,
    },
}

#[op2]
fn op_alder_test_report(state: &mut OpState, #[serde] event: TestEvent) -> bool {
    let host = state.borrow::<Rc<RefCell<HostState>>>().borrow();
    if let Some(report) = &host.test_reporter {
        report(event);
        true
    } else {
        false
    }
}

#[op2(fast)]
fn op_alder_print(#[string] text: &str, stderr: bool) {
    if stderr {
        eprintln!("{text}");
    } else {
        println!("{text}");
    }
}

#[op2]
#[serde]
fn op_alder_args(state: &mut OpState) -> Vec<String> {
    state
        .borrow::<Rc<RefCell<HostState>>>()
        .borrow()
        .args
        .clone()
}

#[op2(fast)]
fn op_alder_exit(state: &mut OpState, code: i32) {
    state
        .borrow::<Rc<RefCell<HostState>>>()
        .borrow_mut()
        .exit_code = code;
}

#[op2(async(deferred), fast)]
async fn op_alder_sleep(#[number] milliseconds: u64) {
    tokio::time::sleep(Duration::from_millis(milliseconds)).await;
}

deno_core::extension!(
    alder_host,
    deps = [
        deno_webidl,
        deno_web,
        deno_fs,
        deno_fetch,
        deno_crypto,
        deno_net,
        deno_websocket,
        deno_http
    ],
    ops = [op_alder_print, op_alder_args, op_alder_exit, op_alder_sleep, op_alder_test_report],
    esm_entry_point = "ext:alder_host/bootstrap.js",
    esm = [dir "js", "bootstrap.js"],
);

const BOOTSTRAP: &str = r#"
const core = Deno.core;
const render = (value) => typeof value === "string" ? value :
    value instanceof Error ? (value.stack ?? value.message) :
    (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
globalThis.console = {
    log: (...values) => core.ops.op_alder_print(values.map(render).join(" "), false),
    error: (...values) => core.ops.op_alder_print(values.map(render).join(" "), true),
};
globalThis.setTimeout = (callback, milliseconds = 0, ...args) => {
    let cancelled = false;
    core.ops.op_alder_sleep(milliseconds).then(() => { if (!cancelled) callback(...args); });
    return { cancel: () => { cancelled = true; } };
};
globalThis.clearTimeout = (timer) => timer?.cancel?.();
Object.defineProperty(globalThis, "__alderHost", {
    value: Object.freeze({
        args: core.ops.op_alder_args(),
        exit: (code) => core.ops.op_alder_exit(code),
        reportTest: (event) => core.ops.op_alder_test_report(event),
    }),
    enumerable: false,
    configurable: false,
    writable: false,
});
"#;

pub async fn execute(bundle: String, args: Vec<String>) -> Result<i32, deno_core::error::AnyError> {
    execute_inner(bundle, args, None).await
}

/// Execute a test bundle with structured results. User stdout/stderr are unchanged.
pub async fn execute_tests(
    bundle: String,
    report: impl Fn(TestEvent) + 'static,
) -> Result<i32, deno_core::error::AnyError> {
    execute_inner(bundle, Vec::new(), Some(Box::new(report))).await
}

async fn execute_inner(
    bundle: String,
    args: Vec<String>,
    test_reporter: Option<Box<dyn Fn(TestEvent)>>,
) -> Result<i32, deno_core::error::AnyError> {
    // Workspace consumers may enable more than one rustls provider through
    // unrelated dependencies. Select the provider used by Deno's pinned TLS
    // stack explicitly so fetch never relies on feature inference.
    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
    let state = Rc::new(RefCell::new(HostState {
        args,
        exit_code: 0,
        test_reporter,
    }));
    let host_state = state.clone();
    let mut runtime = JsRuntime::new(RuntimeOptions {
        extensions: vec![
            deno_webidl::deno_webidl::init(),
            deno_web::deno_web::init(
                Arc::new(deno_web::BlobStore::default()),
                None,
                false,
                deno_web::InMemoryBroadcastChannel::default(),
            ),
            deno_fs::deno_fs::init(Rc::new(deno_fs::RealFs)),
            deno_fetch::deno_fetch::init(Default::default()),
            deno_crypto::deno_crypto::init(None),
            deno_net::deno_net::init(None, None),
            deno_websocket::deno_websocket::init(),
            deno_http::deno_http::init(Default::default()),
            alder_host::init(),
        ],
        ..Default::default()
    });
    {
        let op_state_handle = runtime.op_state();
        let mut op_state = op_state_handle.borrow_mut();
        op_state.put(host_state);
        op_state.put(PermissionsContainer::allow_all(Arc::new(
            RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
        )));
        op_state.put(Arc::new(deno_features::FeatureChecker::default()));
    }
    runtime.execute_script("alder:bootstrap", BOOTSTRAP)?;
    let specifier = ModuleSpecifier::parse("alder://bundle/main.mjs")?;
    let module = runtime
        .load_main_es_module_from_code(&specifier, ModuleCodeString::from(bundle))
        .await?;
    let evaluation = runtime.mod_evaluate(module);
    runtime.run_event_loop(Default::default()).await?;
    evaluation.await?;
    let exit_code = state.borrow().exit_code;
    Ok(exit_code)
}

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

    #[tokio::test(flavor = "current_thread")]
    async fn executes_esm_and_records_exit_code() {
        let code =
            "globalThis.__alderHost.exit(__alderHost.args.length); export default 0;".to_owned();
        assert_eq!(execute(code, vec!["one".to_owned()]).await.unwrap(), 1);
    }

    #[tokio::test(flavor = "current_thread")]
    async fn structured_test_reporting_is_opt_in_and_preserves_exit_status() {
        let code = "const handled = __alderHost.reportTest({kind: 'finished', passed: 2, failed: 1}); __alderHost.exit(handled ? 7 : 3);".to_owned();
        assert_eq!(execute(code.clone(), Vec::new()).await.unwrap(), 3);
        let events = Rc::new(RefCell::new(Vec::new()));
        let received = events.clone();
        assert_eq!(
            execute_tests(code, move |event| received.borrow_mut().push(event))
                .await
                .unwrap(),
            7
        );
        assert_eq!(
            *events.borrow(),
            vec![TestEvent::Finished {
                passed: 2,
                failed: 1
            }]
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn installs_web_fetch_url_and_crypto_globals() {
        let code = r#"
const response = await fetch("data:text/plain,web-ok");
const valid = await response.text() === "web-ok"
    && new URL("https://example.com/path").hostname === "example.com"
    && typeof crypto.randomUUID() === "string";
__alderHost.exit(valid ? 0 : 1);
"#
        .to_owned();
        assert_eq!(execute(code, Vec::new()).await.unwrap(), 0);
    }
}