JavaScript

View Source

Run JavaScript that arrives at request time, in a sandbox that gets one directory and nothing else. You need this page when you are deciding whether the thing you want to run can run here: it is mostly a list of what is not promised, because that is the half you cannot discover from a working example.

Run one

{ok, _} = worker_reaper:start_link(#{scratch => "/var/tmp/js"}),
{ok, W} = js_worker:start_link("test/fixtures/lang/qjs.wasm", #{root => scratch}),
{ok, #{result := #{~"answer" := 42}}} =
    js_worker:run(W, ~"export function main(c) { return {answer: c.value + 1}; }",
                  #{~"value" => 41}).

The tenant writes an ES module exporting main:

export function main(context) {
    return { answer: context.value + 1 };
}

context is whatever you passed, decoded from JSON, so its keys are binaries on the way in and strings in JavaScript. What main returns is encoded the same way and comes back under result.

What you get

The language, and the engine's own library. test/fixtures/lang/QUICKJS.md records which build, and every answer in it was taken by running that build rather than from its documentation.

Capability-controlled file access: the worker stages your source and context into one read-only mount and grants nothing else. A guest that tries to create a file there gets ENOTCAPABLE, and a guest that tries to climb out of the preopen gets the same, both asserted from inside the sandbox in wasm_adapter_conformance.

What you do not get

A runtime module resolver. import resolves the absolute path the host staged. npm works the way it works on Cloudflare Workers: bundle before submitting. What is absent is the resolver, not the packages.

Node built-ins. None. std is QuickJS's own and the bootstrap uses it; nothing re-exports fs or path.

A network. Denied here by choice rather than missing, and the difference matters. An absent net key is no network at all however much the engine imports, so granting it is a wasi_net rule naming addresses and ports. Note what those knobs bound, because neither is what a Workers user expects: max_sockets caps the descriptors an instance holds at once, and timeout bounds one blocking call (WASI guide). Neither is a total subrequest cap, and neither is an end-to-end deadline for one subrequest. A host that wants those counts them itself.

Threads, Worker, or an event loop that outlives the call. The profile is a finite sequence of calls and the bound is wall clock, which is also why there is no ctx.waitUntil.

The WasmEdge extensions the interim artifact carries. A script that relies on them is relying on that artifact rather than on the profile.

Skip the engine start, with the reactor build

Starting QuickJS is most of a small request: measured between 173 and 190 ms against 21 ms for the same work once the engine is already up. The reactor artifact and qjs_reactor_adapter are how you get the second number. Build it, then point a worker at it:

scripts/build-quickjs-reactor.sh
{ok, _} = worker_reaper:start_link(#{scratch => "/var/tmp/js"}),
{ok, W} = script_worker:start_link(
            qjs_reactor_adapter,
            #{path => "test/fixtures/lang/qjs_reactor.wasm", root => scratch,
              limits => #{timeout => 30_000, fuel => infinity,
                          max_memory_pages => 4096,
                          max_heap_words => 16 * 1024 * 1024}}),
{ok, #{result := #{~"answer" := 42}}} =
    script_worker:run(W, #{source => <<"export function main(c)"
                                       " { return {answer: c.value + 1}; }">>,
                           context => #{~"value" => 41}}).

The tenant contract is unchanged: the same main(context), the same JSON in and out, the same capabilities. What changes is that the engine is started once when the worker starts and each request restores an image of that point.

Notes:

  • Isolation is unchanged. A restore builds a fresh instance, so one request's globals never reach the next. That is not a claim about a reused interpreter, because there is no reused interpreter.
  • fuel => infinity is required, as it is for the command artifact: the untrusted preset's ceiling does not reach the engine's first line. The deadline is what bounds a runaway.
  • It needs a WASI SDK to build, which the fetched command artifact does not. test/fixtures/lang/QUICKJS.md has the pins.
  • The numbers, their null experiment and where the time goes are in test/audit/PERF.md.

Give the runner a heap floor

Do this. It is the largest single thing you can do to a QuickJS request and it is one option:

{ok, W} = script_worker:start_link(
            qjs_reactor_adapter,
            #{path => "test/fixtures/lang/qjs_reactor.wasm", root => scratch,
              runner_min_heap_words => 200_000,
              limits => #{timeout => 30_000, fuel => infinity,
                          max_memory_pages => 4096,
                          max_heap_words => 16 * 1024 * 1024}}).

56.0 ms a request becomes 21.1 ms, because 61% of an unfloored request was garbage collection: a restored instance keeps almost nothing on the runner's own heap, so the collector gives it 233 words and collects 98 times through a call that allocates hundreds of millions.

Note the option sits beside root and not inside limits. A floor is not a bound, and one written into the limits map is ignored silently.

200,000 words is QuickJS's knee and it plateaus there; the knee is a property of the guest, and the tuning guide is how to find one for a different build. There is a matching capture_min_heap_words for the worker start, which is worth little here because QuickJS captures in well under a second.

The bound is wall clock, or work, and not both

wasm_limits:untrusted/0 sets a fuel ceiling, and wasm_jit:entry/3 enables generated code only when fuel is infinity. So the two named configurations are exclusive, and a host that sets compile => true and keeps the fuel ceiling gets neither an error nor a tier: it gets the interpreter, quietly. wasm_worker_lang_SUITE asserts exactly that over 500 requests.

Under compiled the only thing between the node and a runaway script is the guardian's wall-clock deadline. That is a security statement rather than a tuning note.

Errors

Everything arrives as a value. A tenant exception comes back as

{error, #{class := adapter, kind := adapter_failure,
          msg := ~"boom",
          ctx := #{code := ~"exception", stdout := _, stderr := _}}}

code is a binary and always will be: the atom table is node-wide and never reclaimed, so nothing a guest names can reach it.