EarendilEarendildjsHow djs works
Source

The Rust interface

A Rust program embeds djs natively, without wasm, through the crates/djs-embed crate: a server that runs user-supplied JavaScript policies on each request, a tool that evaluates configuration scripts, any host that wants a small sandboxed interpreter in its own process. The crate has its own version and an API that exposes nothing of the engine’s internals, so the engine can change underneath it. Its crate documentation (crates/djs-embed/src/lib.rs) is the reference; this page is the overview.

In short. The host compiles its setup (host functions, data, scripts and modules) once into a snapshot, an image of a VM after that setup. Each call runs in an instance made from the snapshot by copying the image, or in one reset to it. A call is stepped in slices: the host answers host calls, starts async work while the script waits, and gets the settled value of the function’s promise as typed data. Fuel, memory and a deadline bound every call, and no script can catch them; every failure is one error type, and a bug in the engine poisons only the instance it happened in.

Snapshots

Setup runs once. The builder defines the host functions the scripts may call and any plain-data globals, then runs the setup sources in order, each to completion:

let snapshot = Snapshot::builder()
    .host_fn(HostFn::sync(LOG, "log"))
    .host_fn(HostFn::asynchronous(READ_BODY, "readBody"))
    .global("config", Data::object([("allow", Data::from("example.com"))]))
    .source(Source::script("shim.js", SHIM))
    .source(Source::script("policy.js", policy))
    .build()?;

A syntax error, a module that cannot be loaded or linked, or a top-level exception fails the build, so a bad policy is rejected when it is loaded rather than on its first request. The builtins are frozen by default: a script cannot patch Array.prototype or JSON to influence the next call. The snapshot is the heap image of the VM at the end of setup: immutable, shared by reference counting, safe to use from any thread. It lives in memory only; building it again from the sources is how a host gets it back.

An instance starts from it by copying the image: there is no parsing, no compiling and no setup code to run, so making one costs about as much as copying a few hundred kilobytes. Resetting an instance copies the image into the heap memory the instance already has, which also saves the allocation. A host chooses per use:

  • A fresh or reset instance per call. Nothing of one call is visible to the next, and results do not depend on which instance served a call.
  • A long-lived instance. Globals persist from call to call, so a script can cache or count. Calls are cheaper by the reset, but the state is per instance.

A reset keeps the heap memory as large as calls grew it, so the next call does not allocate again. A pool that does not want an instance to hold on to what one large call needed (a big request body) reads heap_size after the reset and gives the rest back with shrink_heap; the instance then holds what a fresh one does and grows again when a call needs it.

Calls

start looks a function up by its global path ("onRequest", or "hooks.onRequest" with this the object it was found on) and calls it with typed arguments. step then runs up to one slice of fuel and says why it stopped:

vm.start("onRequest", &[Value::Wire(&head), Value::Number(stream_id)])?;
let decision = loop {
    match vm.step()? {
        Step::Done => break vm.result()?.to_data()?,
        Step::Yield => tokio::task::yield_now().await,
        Step::HostCall(id) => {
            let call = vm.host_call(id)?;
            let reply = serve_sync(call.fn_id(), call.arg(0).as_str());
            call.reply(reply)?;
        }
        Step::Wait(started) => {
            for id in started { pending.push(start_async(&mut vm, id)?); }
            let (id, body) = next_finished(&mut pending).await;
            match body {
                Ok(bytes) => vm.resolve(id, Ok(Value::Bytes(&bytes)))?,
                Err(e) => vm.resolve(id, Err(Throw::error(e.to_string())))?,
            }
        }
    }
};
  • Done. The call settled. For an async function this is when its promise settled, however many times the script waited on the host before. The value is kept safe from the collector until the next call starts. Async host calls that nothing awaited any more are dropped with the rest of the call.
  • A host call. A sync host function was called, and the script waits for the answer at the call: for short, bounded work such as parsing or logging.
  • Wait. The script waits on async host functions: each call returned a promise and the script went on until nothing else could run. The step lists the calls made since the last wait; the host starts their work and answers them in any order, at any time. Each answer settles its promise as a separate task, in the order the host delivered them, so the script’s view does not depend on how the host’s work interleaved.
  • Yield. A slice of fuel is used up. The host can give its thread to other work and step again; a slow script never holds a worker for long.

For a host that answers everything at once (no I/O), call runs this loop with a closure and returns the result as data.

Modules

A policy can be an ES module that imports what the host provides and helpers of its own (modules describes the language side):

let snapshot = Snapshot::builder()
    .module(
        SyntheticModule::new("@sdk/net")
            .host_fn(HostFn::sync(ALLOW_HOSTS, "allowHosts"))
            .data("secret", Data::from(token)),
    )
    .loader(ModuleMap::from_iter(user_files)) // key -> source, in memory
    .source(Source::module("policy.js", policy))
    .build()?;

let mut vm = snapshot.instantiate(Limits::default())?;
vm.start_export("policy.js", "onRequest", &[Value::Data(&request)])?;

A synthetic module has no source: its exports are host functions, answered by id like any other, and plain data. Modules import it by its exact key. Every other import goes to the snapshot’s loader, a trait with two calls, one that resolves a specifier against the importing module’s key and one that returns a key’s source, JavaScript, TypeScript or JSON. A JSON module is imported with { type: "json" } and has the parsed text as its default export: policy data in its own file. The crate’s in-memory map is one: its keys are names such as lib/hosts.js, and ./ and ../ specifiers are taken relative to the importer’s key. The engine itself reads no files.

Setup loads the module and everything it imports, links the graph (a missing export fails the build) and evaluates it, so the snapshot holds the evaluated graph and an instance starts with it, without running module code again. start_export and call_export call an exported function as start and call call a global one, and export reads an export’s current value.

An import() in a module or a script goes to the same loader. The instance answers it inside step, between two slices, so the host sees no step for it: the graph is loaded, compiled and linked, then evaluated, and the promise gets the namespace; a module that cannot be found, does not compile or throws rejects it instead. Modules loaded this way stay in the instance until it is reset. Loading and compiling run no JavaScript, so they use no fuel, but they count against the heap cap.

Values

Arguments and answers go in as typed values: undefined, null, booleans, numbers, strings, text as UTF-8 bytes, bytes (which become a Uint8Array), arrays and objects of such values, plain data (Data, an owned tree of the same) and wire bytes, djs’s binary encoding of the same values. Arrays and objects of values borrow their parts, so a host passes a request head built from the strings it already holds, with no allocation per header; the VM builds the objects straight in its heap, as it does for a Data tree. A string or byte slice is copied into the VM’s heap once, and ASCII is copied as it is: text given as bytes (a body) is checked and copied in one pass, and invalid UTF-8 in it becomes U+FFFD. The heap is a single block of memory that the VM owns and moves as it grows, so the VM cannot wrap a host’s buffer in place: bytes are always that one copy.

Values come out as references into the heap: a host call’s arguments and a call’s result. A string reads as &str straight from the heap when it is ASCII and in one piece, and is converted otherwise; a Uint8Array reads as a &[u8] slice of the heap. to_data and to_wire read plain data without running any JavaScript, so a script cannot run code during the read: they take primitives, arrays, Uint8Arrays and plain objects (with their own enumerable data properties, in order), and refuse everything else with an error that names where it was found, such as cannot encode a function at $.headers[2]. That covers accessors, proxies, class instances, Map, Date, symbols, BigInts and cycles. A result object is therefore exactly what the script built, and a host never parses JSON text.

Limits

Each instance has its budgets: a heap cap, fuel per call, the fuel per slice, and an optional wall-clock deadline per call. None of them can be caught or evaded by a script. Fuel and the deadline are checked between slices, outside the script. Reaching the heap cap ends the run on the spot, before any catch or finally block can run, so a policy cannot swallow its own exhaustion and carry on with a decision; this is the same limit as for other hosts, with the catchable form turned off (see Limits and resource control). The deadline is also there for the host to read while it awaits async work, so that slow I/O cannot outlast the call.

Failures

Every outcome that is not a settled value is an error, and the call is then over: the VM is idle, its pending host calls are gone, and the instance takes the next call. The host can treat all of them alike, and fail closed:

outcomeerror
the function throws, or its promise is rejectedException, with the message and stack
the fuel budget is used upOutOfFuel
the deadline passedDeadline
the heap cap is reachedOutOfMemory
nothing can run, nothing is pending, and the promise is still pendingNeverSettles
a result that is not plain dataData
the host misused the APIUsage
a bug in djsPanicked, then Poisoned

An instance that failed may hold half-updated globals; a host that reuses instances resets one after a failure. A bug in the engine shows as a panic, which the crate catches at every entry into the engine. It poisons that instance (drop it and make a new one) and nothing else: djs keeps no state outside the VM’s own heap, so the snapshot and every other instance are untouched. This relies on Rust’s default unwinding panics; a program built to abort on panic ends instead.

Threads

A snapshot is shared across threads freely. An instance serves one call at a time and can move between threads, between calls or between steps of one call, which is what an async host does when a task resumes on another worker. Parallel calls take one instance each.

Adding it to a project

djs is not on crates.io yet, so a project depends on the repository: by path while it develops against a local checkout, and by a pinned revision otherwise:

[dependencies]
djs-embed = { git = "https://github.com/earendil-works/djs", rev = "<commit>" }
# or, next to a checkout: djs-embed = { path = "../djs/crates/djs-embed" }

[profile.dev.package.djs-core]
opt-level = 1

The profile line is for debug builds: unoptimized, the engine runs several times slower. The crate’s one feature is unicode, on by default: Unicode property escapes in regular expressions and String.prototype.normalize, about 60 KB of native code. The engine’s other build options (dumps, lazy compilation, the compile cache) stay off, since an embedder does not use them. What the build pulls in at run time is four crates of the repository, the engine, its base image, its math functions and the embedding crate, and no third-party crate.

The engine is plain Rust with no C code and no target-specific build step, so it cross-compiles with nothing but a linker for the target, for example zig cc for static linux-musl binaries. The base image, the engine’s realm with all builtins that every snapshot starts from, is made at build time on the build machine and embedded; its format is the same on every target (little-endian cells, 32-bit offsets, the same math results), so a cross build embeds exactly the image the target would have made. scripts/cross-check.sh checks this: it builds a small program for aarch64 and x86_64 macOS and linux-musl, runs each (x86_64 macOS under Rosetta, linux in a container), and compares their output, the embedded image and an image each makes itself.

Costs

crates/djs-embed/examples/hooks-bench/main.rs measures a network hook: a shim of about 330 lines that gives policies Request, Response and Headers over three host functions (URL parsing, the body as an async read, logging), a request head with eight headers going in as borrowed values, and the decision coming out as plain data. cargo run --release -p djs-embed --example hooks-bench -- 10000 prints the table. On an Apple M-series machine shared with other work (load average 30 to 70), the median per call was:

policyinstancemedianfuel
empty (the shim alone)long-lived3.7 µs312
header allowlist and rewritelong-lived35 µs5,763
header allowlist and rewritereset per call42 µs5,763
header allowlist and rewritefresh per call43 µs5,763
64 KiB body read as text, unusedlong-lived12.5 µs1,132
64 KiB body read as bytes, unusedlong-lived14.7 µs1,501
64 KiB body read and searchedlong-lived26 µs1,158

The same allowlist as a native Rust closure takes 1 µs. Compiling the shim and a policy into a snapshot takes about 6 ms, and the snapshot is 237 KiB. Where the time goes:

  • The script. Running the policy and the shim is most of every call: about 60% of the empty policy (wrapper objects, private fields, the promise chain) and 75% of the header policy, at roughly 4 to 5 ns per unit of fuel. A policy written with plain loops costs less than one built on iterators and sorting.
  • A fresh or reset instance costs one copy of the snapshot, about 3 µs for this one: the copy runs at memory speed, and a smaller setup makes a smaller snapshot. A long-lived instance skips it.
  • Values in and out take well under a microsecond for a request head and its decision: no buffer in between, no JSON.
  • Bytes and text are one copy into the heap, plus a check for text. A long-lived instance that takes large bodies collects garbage every few calls, and each collection reads all of the snapshot’s memory.

Where to look