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.
quickjs_oxide::engine::api::core::SystemHostis the trait's defaults: the process's streams and file system, the system clock, UTC.Runtime::new()andRuntime::with_optionsuse it.quickjs_oxide_host::SystemHost, from thequickjs-oxide-hostpackage inadapters/native, adds the system's time zone. It follows theTZenvironment variable (an IANA name or a POSIX rule) and otherwise the system's local zone, and seedsMath.randomfrom the current time in microseconds, as QuickJS does. Theqjscommand uses it.quickjs_oxide_web_host::WebHost, from thequickjs-oxide-web-hostpackage inadapters/web, is forwasm32-unknown-unknown. It reads the browser'sDate.now()and time zone, seeds fromMath.random(), discardswriteoutput and has no files.
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.
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:
/// 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.
/// 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:
print(...args)andconsole.log(...args), writing toStream::Stdout;performance.now();scriptArgs, an array of strings, whenQjsHelpers::script_argsisSome;load(path), which runs a file as a script, andread(path), which returns a file's text, whenQjsHelpers::stdistrue.
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.
/// 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:
RejectionEvent::Rejectedwhen a promise is rejected with no handler attached;RejectionEvent::Handledwhen a handler is attached to a promise that was rejected without one.
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.
/// 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
- Override only the methods you need; the rest keep the process defaults. To keep a script away from the real file system, override
read_file, or do not install the helpers withstd: true. - A host method has no access to the runtime. It cannot run JavaScript or create values.
- A runtime and its host belong to one thread.
RuntimeholdsRcstate and is notSend, so create both on the thread that uses them.
The complete program is examples/host.rs:
cargo run --quiet --example hostWhere to go next
- Promises and jobs: drain the job queue and report unhandled rejections.
- Modules: load modules through a
ModuleLoader. - Errors and limits: runtime options, exceptions and the memory cap.
- WebAssembly: run the engine in a browser with
WebHost.