Skip to main content

alder_runtime/
lib.rs

1//! A small, capability-oriented host around `deno_core`.
2
3use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
4
5use deno_core::{JsRuntime, ModuleCodeString, ModuleSpecifier, OpState, RuntimeOptions, op2};
6use deno_permissions::{PermissionsContainer, RuntimePermissionDescriptorParser};
7
8mod dev;
9pub use dev::{DevEvent, execute_dev};
10
11#[derive(Default)]
12struct HostState {
13    args: Vec<String>,
14    exit_code: i32,
15    test_reporter: Option<Box<dyn Fn(TestEvent)>>,
16    build_output: Option<Rc<RefCell<Option<String>>>>,
17}
18
19#[op2(fast)]
20fn op_alder_build_output(
21    state: &mut OpState,
22    #[string] output: String,
23) -> Result<(), deno_error::JsErrorBox> {
24    let host = state.borrow::<Rc<RefCell<HostState>>>().borrow();
25    let sink = host
26        .build_output
27        .as_ref()
28        .ok_or_else(|| deno_error::JsErrorBox::generic("No build evaluation is active"))?;
29    let mut sink = sink.borrow_mut();
30    if sink.is_some() {
31        return Err(deno_error::JsErrorBox::generic(
32            "Build evaluation reported more than one result",
33        ));
34    }
35    *sink = Some(output);
36    Ok(())
37}
38
39/// Results from the test runner, separate from the program's console streams.
40#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
41#[serde(tag = "kind", rename_all = "camelCase")]
42pub enum TestEvent {
43    Passed {
44        module: String,
45        name: String,
46    },
47    Failed {
48        module: String,
49        name: String,
50        message: String,
51    },
52    Finished {
53        passed: usize,
54        failed: usize,
55    },
56}
57
58#[op2]
59fn op_alder_test_report(state: &mut OpState, #[serde] event: TestEvent) -> bool {
60    let host = state.borrow::<Rc<RefCell<HostState>>>().borrow();
61    if let Some(report) = &host.test_reporter {
62        report(event);
63        true
64    } else {
65        false
66    }
67}
68
69#[op2(fast)]
70fn op_alder_print(#[string] text: &str, stderr: bool) {
71    if stderr {
72        eprintln!("{text}");
73    } else {
74        println!("{text}");
75    }
76}
77
78#[op2]
79#[serde]
80fn op_alder_args(state: &mut OpState) -> Vec<String> {
81    state
82        .borrow::<Rc<RefCell<HostState>>>()
83        .borrow()
84        .args
85        .clone()
86}
87
88#[op2(fast)]
89fn op_alder_exit(state: &mut OpState, code: i32) {
90    state
91        .borrow::<Rc<RefCell<HostState>>>()
92        .borrow_mut()
93        .exit_code = code;
94}
95
96#[op2(async(deferred), fast)]
97async fn op_alder_sleep(#[number] milliseconds: u64) {
98    tokio::time::sleep(Duration::from_millis(milliseconds)).await;
99}
100
101deno_core::extension!(
102    alder_host,
103    deps = [
104        deno_webidl,
105        deno_web,
106        deno_fs,
107        deno_fetch,
108        deno_crypto,
109        deno_net,
110        deno_websocket,
111        deno_telemetry,
112        deno_http
113    ],
114    ops = [op_alder_print, op_alder_args, op_alder_exit, op_alder_sleep, op_alder_test_report, op_alder_build_output, dev::op_alder_dev_event],
115    esm_entry_point = "ext:alder_host/bootstrap.js",
116    esm = [dir "js", "bootstrap.js"],
117);
118
119const BOOTSTRAP: &str = r#"
120const core = Deno.core;
121const render = (value) => typeof value === "string" ? value :
122    value instanceof Error ? (value.stack ?? value.message) :
123    (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
124globalThis.console = {
125    log: (...values) => core.ops.op_alder_print(values.map(render).join(" "), false),
126    error: (...values) => core.ops.op_alder_print(values.map(render).join(" "), true),
127};
128Object.defineProperty(globalThis, "__alderHost", {
129    value: Object.freeze({
130        args: core.ops.op_alder_args(),
131        exit: (code) => core.ops.op_alder_exit(code),
132        reportTest: (event) => core.ops.op_alder_test_report(event),
133        serve: globalThis.__alderServe,
134        devEvent: () => core.ops.op_alder_dev_event(),
135        reportBuild: (value) => core.ops.op_alder_build_output(value),
136    }),
137    enumerable: false,
138    configurable: false,
139    writable: false,
140});
141"#;
142
143pub async fn execute(bundle: String, args: Vec<String>) -> Result<i32, deno_core::error::AnyError> {
144    execute_inner(bundle, args, None, None, None).await
145}
146
147/// Evaluate a compiler-generated build entry without scraping program output.
148pub async fn execute_build(bundle: String) -> Result<String, deno_core::error::AnyError> {
149    let output = Rc::new(RefCell::new(None));
150    let status = execute_inner(bundle, Vec::new(), None, None, Some(output.clone())).await?;
151    if status != 0 {
152        return Err(deno_error::JsErrorBox::generic(format!(
153            "Build evaluation exited with {status}"
154        ))
155        .into());
156    }
157    let value = output.borrow_mut().take().ok_or_else(|| {
158        deno_error::JsErrorBox::generic("Build evaluation did not return a result")
159    })?;
160    Ok(value)
161}
162
163/// Execute a test bundle with structured results. User stdout/stderr are unchanged.
164pub async fn execute_tests(
165    bundle: String,
166    report: impl Fn(TestEvent) + 'static,
167) -> Result<i32, deno_core::error::AnyError> {
168    execute_inner(bundle, Vec::new(), Some(Box::new(report)), None, None).await
169}
170
171async fn execute_inner(
172    bundle: String,
173    args: Vec<String>,
174    test_reporter: Option<Box<dyn Fn(TestEvent)>>,
175    dev: Option<Rc<dev::DevState>>,
176    build_output: Option<Rc<RefCell<Option<String>>>>,
177) -> Result<i32, deno_core::error::AnyError> {
178    // Workspace consumers may enable more than one rustls provider through
179    // unrelated dependencies. Select the provider used by Deno's pinned TLS
180    // stack explicitly so fetch never relies on feature inference.
181    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
182    let state = Rc::new(RefCell::new(HostState {
183        args,
184        exit_code: 0,
185        test_reporter,
186        build_output,
187    }));
188    let host_state = state.clone();
189    let mut runtime = JsRuntime::new(RuntimeOptions {
190        module_loader: dev
191            .as_ref()
192            .map(|state| state.clone() as Rc<dyn deno_core::ModuleLoader>),
193        extension_transpiler: Some(Rc::new(|name, source| {
194            if !name.ends_with(".ts") {
195                return Ok((source, None));
196            }
197            // Only pinned upstream extension TypeScript crosses this parser;
198            // Alder application code continues to arrive as emitted ESM.
199            let allocator = oxc_allocator::Allocator::default();
200            let parsed =
201                oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::ts()).parse();
202            if !parsed.errors.is_empty() {
203                return Err(deno_error::JsErrorBox::generic(format!(
204                    "cannot parse runtime extension {name}: {:?}",
205                    parsed.errors
206                )));
207            }
208            let mut program = parsed.program;
209            let semantic = oxc_semantic::SemanticBuilder::new().build(&program);
210            if !semantic.errors.is_empty() {
211                return Err(deno_error::JsErrorBox::generic(format!(
212                    "cannot analyze runtime extension {name}: {:?}",
213                    semantic.errors
214                )));
215            }
216            let options = oxc_transformer::TransformOptions::default();
217            let transformed = oxc_transformer::Transformer::new(
218                &allocator,
219                std::path::Path::new(name.as_str()),
220                &options,
221            )
222            .build_with_scoping(semantic.semantic.into_scoping(), &mut program);
223            if !transformed.errors.is_empty() {
224                return Err(deno_error::JsErrorBox::generic(format!(
225                    "cannot lower runtime extension {name}: {:?}",
226                    transformed.errors
227                )));
228            }
229            Ok((
230                oxc_codegen::Codegen::new().build(&program).code.into(),
231                None,
232            ))
233        })),
234        extensions: vec![
235            deno_webidl::deno_webidl::init(),
236            deno_web::deno_web::init(
237                Arc::new(deno_web::BlobStore::default()),
238                None,
239                false,
240                deno_web::InMemoryBroadcastChannel::default(),
241            ),
242            deno_fs::deno_fs::init(Rc::new(deno_fs::RealFs)),
243            deno_fetch::deno_fetch::init(Default::default()),
244            deno_crypto::deno_crypto::init(None),
245            deno_net::deno_net::init(None, None),
246            deno_websocket::deno_websocket::init(),
247            deno_telemetry::deno_telemetry::init(),
248            deno_http::deno_http::init(Default::default()),
249            alder_host::init(),
250        ],
251        ..Default::default()
252    });
253    {
254        let op_state_handle = runtime.op_state();
255        let mut op_state = op_state_handle.borrow_mut();
256        op_state.put(host_state);
257        if let Some(dev) = dev {
258            op_state.put(dev);
259        }
260        op_state.put(PermissionsContainer::allow_all(Arc::new(
261            RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
262        )));
263        op_state.put(Arc::new(deno_features::FeatureChecker::default()));
264    }
265    runtime.execute_script("alder:bootstrap", BOOTSTRAP)?;
266    let specifier = ModuleSpecifier::parse("alder://bundle/main.mjs")?;
267    let module = runtime
268        .load_main_es_module_from_code(&specifier, ModuleCodeString::from(bundle))
269        .await?;
270    let evaluation = runtime.mod_evaluate(module);
271    runtime.run_event_loop(Default::default()).await?;
272    evaluation.await?;
273    let exit_code = state.borrow().exit_code;
274    Ok(exit_code)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[tokio::test(flavor = "current_thread")]
282    async fn build_report_is_structured_and_independent_of_console_output() {
283        let report = execute_build(r#"console.log("not the build result"); console.error("also not the result"); await Promise.resolve(); __alderHost.reportBuild('{"pages":["/one"]}');"#.to_owned()).await.unwrap();
284        assert_eq!(report, r#"{"pages":["/one"]}"#);
285    }
286
287    #[tokio::test(flavor = "current_thread")]
288    async fn build_report_requires_exactly_one_result_and_successful_exit() {
289        for (code, expected) in [
290            ("console.log('no result');", "did not return a result"),
291            (
292                "__alderHost.reportBuild('one'); __alderHost.reportBuild('two');",
293                "more than one result",
294            ),
295            (
296                "__alderHost.reportBuild('result'); __alderHost.exit(7);",
297                "exited with 7",
298            ),
299            (
300                "__alderHost.reportBuild('result'); throw new Error('later failure');",
301                "later failure",
302            ),
303        ] {
304            let error = execute_build(code.to_owned()).await.unwrap_err();
305            assert!(error.to_string().contains(expected), "{error}");
306        }
307    }
308
309    #[tokio::test(flavor = "current_thread")]
310    async fn build_report_sink_is_isolated_between_runs_and_disabled_otherwise() {
311        assert_eq!(
312            execute_build("__alderHost.reportBuild('first');".to_owned())
313                .await
314                .unwrap(),
315            "first"
316        );
317        assert_eq!(
318            execute_build("__alderHost.reportBuild('second');".to_owned())
319                .await
320                .unwrap(),
321            "second"
322        );
323        let error = execute("__alderHost.reportBuild('not allowed');".to_owned(), vec![])
324            .await
325            .unwrap_err();
326        assert!(
327            error.to_string().contains("No build evaluation is active"),
328            "{error}"
329        );
330    }
331
332    #[tokio::test(flavor = "current_thread")]
333    async fn executes_esm_and_records_exit_code() {
334        let code =
335            "globalThis.__alderHost.exit(__alderHost.args.length); export default 0;".to_owned();
336        assert_eq!(execute(code, vec!["one".to_owned()]).await.unwrap(), 1);
337    }
338
339    #[tokio::test(flavor = "current_thread")]
340    async fn structured_test_reporting_is_opt_in_and_preserves_exit_status() {
341        let code = "const handled = __alderHost.reportTest({kind: 'finished', passed: 2, failed: 1}); __alderHost.exit(handled ? 7 : 3);".to_owned();
342        assert_eq!(execute(code.clone(), Vec::new()).await.unwrap(), 3);
343        let events = Rc::new(RefCell::new(Vec::new()));
344        let received = events.clone();
345        assert_eq!(
346            execute_tests(code, move |event| received.borrow_mut().push(event))
347                .await
348                .unwrap(),
349            7
350        );
351        assert_eq!(
352            *events.borrow(),
353            vec![TestEvent::Finished {
354                passed: 2,
355                failed: 1
356            }]
357        );
358    }
359
360    #[tokio::test(flavor = "current_thread")]
361    async fn installs_web_fetch_url_and_crypto_globals() {
362        let code = r#"
363const response = await fetch("data:text/plain,web-ok");
364const valid = await response.text() === "web-ok"
365    && new URL("https://example.com/path").hostname === "example.com"
366    && typeof crypto.randomUUID() === "string";
367__alderHost.exit(valid ? 0 : 1);
368"#
369        .to_owned();
370        assert_eq!(execute(code, Vec::new()).await.unwrap(), 0);
371    }
372
373    #[tokio::test(flavor = "current_thread")]
374    async fn standalone_http_serves_concurrent_requests_and_releases_listener() {
375        let code = r#"
376const server = __alderHost.serve(async request => {
377  const url = new URL(request.url);
378  return new Response(await request.text() + url.pathname, {headers: {"x-alder": "http"}});
379}, {port: 0});
380const base = `http://127.0.0.1:${server.addr.port}`;
381const responses = await Promise.all(["one", "two"].map(value => fetch(base + "/" + value, {method: "POST", body: value})));
382for (let index = 0; index < responses.length; index++) {
383  const word = index === 0 ? "one" : "two";
384  if (await responses[index].text() !== word + "/" + word || responses[index].headers.get("x-alder") !== "http") throw Error("HTTP response mismatch");
385}
386await server.shutdown();
387await server.shutdown();
388const reused = __alderHost.serve(() => new Response("reused"), {port: server.addr.port});
389await reused.shutdown();
390"#.to_owned();
391        tokio::time::timeout(Duration::from_secs(10), execute(code, vec![]))
392            .await
393            .unwrap()
394            .unwrap();
395    }
396
397    #[tokio::test(flavor = "current_thread")]
398    async fn cancelled_web_timers_do_not_keep_the_runtime_alive() {
399        let code = r#"
400const timeout = setTimeout(() => {throw Error("cancelled timeout ran");}, 60000);
401const interval = setInterval(() => {throw Error("cancelled interval ran");}, 60000);
402clearTimeout(timeout); clearInterval(interval);
403await new Promise(resolve => setTimeout(resolve, 1));
404"#
405        .to_owned();
406        tokio::time::timeout(Duration::from_secs(2), execute(code, vec![]))
407            .await
408            .expect("cancelled timers release event-loop references")
409            .unwrap();
410    }
411}