Run JavaScript
View SourceQuickJS compiled to WebAssembly gives you a JavaScript engine that runs inside the sandbox: no filesystem, no network, no host access unless you grant it, and a time and memory limit per run. You need it when logic has to change without a deploy and the people writing it will write JavaScript, not Erlang.
Get the engine
quickjs-ng publishes a WASI build with every release:
curl -fsSLo priv/qjs-wasi.wasm \
https://github.com/quickjs-ng/quickjs/releases/download/v0.16.2/qjs-wasi.wasm
1.5 MB, sha256 d2939e98c808e8b9f4164cd0d7b0398cbc0121ddf52862bcd92157d923e461cc.
It imports only wasi_snapshot_preview1, so it links with the wasi option
and nothing else.
Compile once, run many
{ok, Bin} = file:read_file("priv/qjs-wasi.wasm"),
{ok, Engine} = wasmtime:compile(Bin).Compiling takes about 80 ms and happens once. Each run instantiates the engine fresh (2 ms) so nothing survives from one script to the next:
{ok, Inst} = wasmtime:instantiate(Engine, #{
wasi => #{args => [~"qjs", ~"-e", ~"console.log(1 + 2, JSON.stringify({a: [1, 2, 3]}))"],
stdout => capture, stderr => capture},
memory_limit => 64 * 1024 * 1024}),
{ok, []} = wasmtime:call(Inst, ~"_start", [], #{timeout => 5000}),
{ok, {~"3 {\"a\":[1,2,3]}\n", <<>>, _}} = wasmtime:read_output(Inst).args is the engine's command line. -e evaluates a string; a path runs a
file.
Run a file
Grant the directory holding the script, read only:
{ok, Inst} = wasmtime:instantiate(Engine, #{
wasi => #{args => [~"qjs", ~"/app/main.js"],
dirs => [{~"/app", "/srv/scripts", read}],
env => [{~"GREETING", ~"hi"}],
stdout => capture, stderr => capture}}),
{ok, []} = wasmtime:call(Inst, ~"_start", [], #{timeout => 5000}),
{ok, {Out, Err, _}} = wasmtime:read_output(Inst).// /srv/scripts/main.js
import * as std from "qjs:std";
console.log("from file:", [1, 2, 3].map(x => x * 2).join(","));
console.log("GREETING =", std.getenv("GREETING"));The script sees /app and nothing else. Without dirs it has no filesystem
at all.
Handle failures
A thrown exception exits with status 1 and its message on stderr:
{error, #{class := exit, status := 1}} = wasmtime:call(Inst, ~"_start", []),
{ok, {<<>>, ~"Error: boom\n at <anonymous> (<cmdline>:1:11)\n\n", _}} = wasmtime:read_output(Inst).An endless loop is stopped by the timeout:
{error, #{kind := timeout}} = wasmtime:call(Inst, ~"_start", [], #{timeout => 5000}).A script that allocates past memory_limit fails inside the engine and exits
non-zero. In every case the instance can be dropped and the next run starts
clean.
Talk to a running script
The runs above are one shot: arguments in, output out when _start returns.
A script can instead stay up and serve requests. Its only channels are stdin
and stdout, so make them streams: what send/2 queues appears on stdin as
the script reads it, and every write to stdout arrives in your mailbox at
once.
// /srv/scripts/worker.js: one JSON request per line in, one reply per line out
import * as std from "qjs:std";
let line;
while ((line = std.in.getline()) !== null) {
const req = JSON.parse(line);
std.out.puts(JSON.stringify({sku: req.sku, price: 42}) + "\n");
}{ok, Inst} = wasmtime:instantiate(Engine, #{
wasi => #{args => [~"qjs", ~"/app/worker.js"],
dirs => [{~"/app", "/srv/scripts", read}],
stdin => stream, stdout => stream, stderr => capture},
stream => self()}),
{ok, Req} = wasmtime:call_async(Inst, ~"_start", []),
Ref = wasmtime:ref(Inst),
ok = wasmtime:send(Inst, [json:encode(#{sku => ~"A1"}), $\n]),
receive {wasmtime_stream, Ref, stdout, Line} -> json:decode(string:chomp(Line)) end,
ok = wasmtime:send(Inst, [json:encode(#{sku => ~"B2"}), $\n]),
receive {wasmtime_stream, Ref, stdout, Line2} -> json:decode(string:chomp(Line2)) end,
ok = wasmtime:close(Inst),
{ok, []} = wasmtime:await(Inst, Req).call_async starts _start without waiting for it: the script blocks in
getline for the next line while your process goes on. close/1 ends its
input, getline returns null, the loop ends and await returns. A
timeout on await/3 or wasmtime:interrupt/1 still stops it at any time.
A streamed stdout looks like a terminal to the script, so each line
leaves as it is written and std.out.flush() is not needed. One line per
request is the script's convention, not a rule: stdin is a byte stream,
and a message from send/2 can be split or merged by the script's own
reads.
examples/js/js.erl wraps this as js:serve/3, js:ask/2 and js:stop/1.
A small wrapper
examples/js/js.erl packages the above:
1> c("examples/js/js.erl").
2> {ok, Engine} = js:load("priv/qjs-wasi.wasm").
3> js:eval(Engine, ~"console.log([1, 2, 3].reduce((a, b) => a + b))").
{ok, ~"6\n"}
4> js:eval(Engine, ~"for (;;) {}", #{timeout => 100}).
{error, timeout}
5> js:eval(Engine, ~"throw new Error('no')").
{error, {exit, 1, ~"Error: no\n at <anonymous> (<cmdline>:1:11)\n\n"}}
6> js:run_file(Engine, "/srv/scripts", "main.js").
{ok, ~"from file: 2,4,6\nGREETING = undefined\n"}
7> {ok, W} = js:serve(Engine, "/srv/scripts", "worker.js").
8> js:ask(W, ~"{\"sku\": \"A1\"}").
{ok, ~"{\"sku\":\"A1\",\"price\":42}\n"}
9> js:stop(W).
okNotes
- Input goes in through
args,env,stdin => {binary, Bytes}or a granted directory; output comes back withstdout/stderr => captureandread_output/1, or through files.streamon both sides keeps a script running and talking. QuickJS has no imports of its own, soimportshost functions do not apply to it. For guest code that should call Erlang directly, see host functions with a module you build. - Use the
qjs-wasi.wasmbuild.qjs-wasi-reactor.wasmhas no_start. Thewasmedge-quickjsbuild imports WasmEdge socket extensions (sock_openand friends) that WASI preview 1 does not have, so it fails to link withclass => link. import * as std from "qjs:std"andqjs:osgive the script environment variables, files under granted directories and timers.os.execand sockets are not reachable: there is no process or network capability in the sandbox.- One engine module serves any number of concurrent runs; each
instantiateis its own store and thread.