Host services

The engine reaches the outside world through one trait, Host. A runtime owns one host for its whole life. The host decides where output goes, what time it is, which time zone applies, how Math.random is seeded, what happens on a promise rejection, and how load and read find files.

The Host trait

Host is defined in core/src/host.rs and re-exported as quickjs_oxide::engine::api::core::Host. Every method has a default, so an implementation overrides only what it needs.

Method Used by Default
write(&mut self, stream: Stream, text: &[u8]) print, console.log writes to the process's stdout or stderr and flushes
now_ms(&mut self) -> f64 Date.now(), new Date() the system clock, in milliseconds since the Unix epoch
local_tz_offset_minutes(&mut self, utc_ms: f64) -> i32 local-time Date methods 0 (UTC)
random_seed(&mut self) -> u64 the initial state of Math.random derived from the system clock
promise_rejection(&mut self, event: RejectionEvent, promise: CoreValue) the promise rejection tracker ignores the event
read_file(&mut self, path: &str) -> Option<String> load and read reads the process's file system

local_tz_offset_minutes returns UTC minus local time at the instant utc_ms, with the sign Date.prototype.getTimezoneOffset uses: a zone east of UTC gives a negative number. The engine asks for every instant it converts, so a host can apply daylight-saving rules.

write receives UTF-8. A lone surrogate in a JavaScript string arrives encoded as is (WTF-8), the bytes QuickJS writes for it.

performance.now() reads the process's monotonic clock, not the host.

Hosts that ship with the engine

Three hosts are available.

The two SystemHost types share a name. Import the one you mean by its full path.

Creating a runtime over a host

A runtime takes its host by value when it is created.

Constructor Host Options
Runtime::new() core SystemHost RuntimeOptions::default()
Runtime::with_options(options) core SystemHost options
Runtime::with_host(host, options) host options
Runtime::new_with_host_services(host) host RuntimeOptions::default()

The host must be 'static. To read state back from it after the runtime has taken it, keep the state behind an Rc<RefCell<…>> and hold a second reference. Errors and limits covers RuntimeOptions.

examples/host.rsrust
let runtime = Runtime::new_with_host_services(quickjs_oxide_host::SystemHost::default());
let mut context = runtime.context();
let offset = context.eval("new Date(0).getTimezoneOffset()").unwrap();

Capturing output

A host that overrides write receives everything print and console.log produce. This one keeps standard output in a shared buffer:

examples/host.rsrust
/// A host that keeps everything `print` and `console.log` write.
#[derive(Clone, Default)]
struct CaptureHost {
    output: Rc<RefCell<Vec<u8>>>,
}

impl Host for CaptureHost {
    fn write(&mut self, stream: Stream, text: &[u8]) {
        if stream == Stream::Stdout {
            self.output.borrow_mut().extend_from_slice(text);
        }
    }
}

fn capture_output() {
    let host = CaptureHost::default();
    let output = Rc::clone(&host.output);
    let runtime = Runtime::new_with_host_services(host);
    let mut context = runtime.context();
    let before = context.eval("typeof print + ' ' + typeof console").unwrap();
    assert_eq!(
        before,
        Value::String(JsString::from_static("undefined undefined"))
    );
    context.install_qjs_helpers(&QjsHelpers::default()).unwrap();

    context
        .eval("print('hello', 1 + 2); console.log('done')")
        .unwrap();

    assert_eq!(&*output.borrow(), b"hello 3\ndone\n");
}

print joins its arguments with spaces and ends the line with \n; each call is one write. A string argument is written as is, and any other value as Runtime::print_value formats it.

Reproducible runs

A host with a fixed clock, a fixed time-zone offset and a fixed seed makes Date and Math.random return the same values on every run. That suits tests, snapshots and replays.

examples/host.rsrust
/// A host with a fixed clock, a fixed time zone and a fixed random seed:
/// every run of a script sees the same `Date` and `Math.random` values.
struct FixedHost;

impl Host for FixedHost {
    fn now_ms(&mut self) -> f64 {
        1_767_225_600_000.0 // 2026-01-01T00:00:00Z
    }

    fn local_tz_offset_minutes(&mut self, _utc_ms: f64) -> i32 {
        -60 // UTC+1: getTimezoneOffset() is UTC minus local time
    }

    fn random_seed(&mut self) -> u64 {
        42
    }
}

fn fixed_clock() {
    let script = "[new Date().toISOString(), new Date().getTimezoneOffset(),
                   new Date().getHours(), Math.random()].join(' ')";
    let run = || {
        let runtime = Runtime::with_host(FixedHost, RuntimeOptions::default());
        let mut context = runtime.context();
        let Value::String(text) = context.eval(script).unwrap() else {
            panic!("expected a string");
        };
        text.to_utf8_lossy()
    };

    let first = run();
    assert!(first.starts_with("2026-01-01T00:00:00.000Z -60 1 0."));
    assert_eq!(first, run());
}

The qjs helpers

A new runtime's global object has the ECMAScript built-ins and nothing that does I/O. Context::install_qjs_helpers adds the globals that QuickJS's qjs defines, all of which go through the host:

QjsHelpers::default() installs the first group without scriptArgs, load or read.

load and read get their text from Host::read_file. A host that serves files from memory, an archive or a sandboxed directory overrides it. When read_file returns None, load throws a ReferenceError and read throws an Error.

examples/host.rsrust
/// A host whose `load` and `read` see an in-memory file system.
struct MemoryFiles {
    files: HashMap<String, String>,
}

impl Host for MemoryFiles {
    fn read_file(&mut self, path: &str) -> Option<String> {
        self.files.get(path).cloned()
    }
}

fn read_file() {
    let files = HashMap::from([
        ("lib.js".to_owned(), "var greeting = 'hi';".to_owned()),
        ("data.txt".to_owned(), "line one".to_owned()),
    ]);
    let runtime = Runtime::new_with_host_services(MemoryFiles { files });
    let mut context = runtime.context();
    context
        .install_qjs_helpers(&QjsHelpers {
            std: true,
            script_args: Some(vec!["main.js".to_owned()]),
        })
        .unwrap();

    let value = context
        .eval("load('lib.js'); greeting + ', ' + read('data.txt') + ', ' + scriptArgs[0]")
        .unwrap();
    let Value::String(text) = value else {
        panic!("expected a string")
    };
    assert_eq!(text.to_utf8_lossy(), "hi, line one, main.js");

    // A path the host does not know is a ReferenceError.
    let value = context
        .eval("try { load('missing.js') } catch (e) { e.name }")
        .unwrap();
    let Value::String(text) = value else {
        panic!("expected a string")
    };
    assert_eq!(text.to_utf8_lossy(), "ReferenceError");
}

Promise rejection events

The engine calls promise_rejection for each operation of ECMAScript's HostPromiseRejectionTracker:

The promise arrives as a CoreValue, the core's raw value. It holds no handle, so it does not keep the promise alive after the call returns. To inspect unhandled rejections from Rust, turn on rejection tracking and call Runtime::take_unhandled_rejections after the job queue drains; Promises and jobs shows how.

examples/host.rsrust
/// A host that records the rejection tracker's events.
#[derive(Clone, Default)]
struct RejectionLog {
    events: Rc<RefCell<Vec<RejectionEvent>>>,
}

impl Host for RejectionLog {
    fn promise_rejection(&mut self, event: RejectionEvent, _promise: CoreValue) {
        self.events.borrow_mut().push(event);
    }
}

fn rejection_events() {
    let host = RejectionLog::default();
    let events = Rc::clone(&host.events);
    let runtime = Runtime::new_with_host_services(host);
    let mut context = runtime.context();

    context
        .eval("globalThis.p = Promise.reject(new Error('late'))")
        .unwrap();
    context.eval("p.catch(() => {})").unwrap();
    runtime.run_jobs().unwrap();

    assert_eq!(
        *events.borrow(),
        [RejectionEvent::Rejected, RejectionEvent::Handled]
    );
}

The host is called for every event whether tracking is on or off.

Writing your own host

The complete program is examples/host.rs:

shell
cargo run --quiet --example host

Where to go next