Chapter 31
The JavaScript Back End
Chapter 26 stated the principle the native generator is built on, and everything in it followed from that one sentence: the generator never reimplements the runtime. It emits C++ that calls the same functions the interpreter calls, which is why --exe and the interpreter produce byte-identical output, and why a feature implemented once works in both.
--target=js gives that up. It has to. There is no librakupp_rt.a in a browser, and no Value, and no applyArith — so a JavaScript program cannot call the runtime the other back ends share. Something has to play its part, and that something is a second runtime, written by hand, in JavaScript.
This chapter is about what happens to a code generator when the principle holding it together is unavailable, and what it costs to keep two runtimes honest instead of one.
The interface
One function, and the same refusal type main.cpp already catches:
// src/codegen/Js.h
std::string transpileToJs(Program& prog, const JsOptions& opt,
std::string* dts = nullptr, std::string* map = nullptr);
std::string jsRuntimeSource(); // the runtime, baked into the binary
std::string jsWasmWrapper(const std::string& src, const JsOptions& opt);
transpileToJs throws CodegenError — the same struct Codegen.h declares — so the CLI reports a JavaScript refusal in exactly the shape it reports a C++ one. That is a small thing and it is the reason the second back end did not need a second error path through main.cpp.
The same program, a third way
Chapter 25 took demo.raku through four back ends. Here is the fifth:
# demo.raku
sub square($n) { $n * $n }
my $total = 0;
for 1..5 { $total += square($_) }
say $total;
// Generated by `rakupp --target=js` — rakupp 3.25.0, source f49826b83f947e47
// RAKUPP-EXE-MANIFEST {"rakupp":"3.25.0","mode":"js","source":"f49826b83f947e47"}
import R from "./rakupp-rt.js";
R.main(() => {
let v___0 = R.Any;
function u_square(v_n) {
let v___2_1 = R.Any;
if (arguments.length !== 1) R.arityError("square", 1, arguments.length);
if (v_n === R.Mu) R.notAny("$n");
v_n = R.item(v_n);
return R.mul(v_n, v_n);
}
let v_total = 0;
{
let v___2_2;
L3: for (v___2_2 of R.rangeIter(1, 5, false, false)) {
{
(v_total = R.add(v_total, (u_square(v___2_2))));
}
}
}
R.sink(R.say(R.item(v_total)));
}, { mainExit: true });
//# sourceMappingURL=demo.js.map
The shape is deliberate and it is the same bargain the C++ back end makes: the program's subs are hoisted function declarations, its my variables are lets, its classes are R.defClass calls, so a reader recognises their own program. Every value operation is a call into R. Control flow is JavaScript's own wherever it is lexically local — that L3: is a real labelled loop, and last compiles to break L3 — and only where control has to cross a closure does the emitter fall back to throwing a control object.
Three lines are worth naming because they are what a signature costs: the arity check, the Mu check, and the itemisation. They are the price of not reimplementing the dispatcher, and they are also why a transpiled sub recurses about 8,900 deep where a plain JavaScript function manages 10,400 — the guards take stack frames too.
The runtime that had to be written
src/js-rt/ is thirteen fragments and 5,070 hand-written lines, concatenated in name order:
05-gb-tables.js | grapheme-break data, so .chars counts clusters in a browser |
10-core.js | the value model, R.add and the rest of the arithmetic |
20-str.js, 30-list.js, 40-objects.js | the three container families |
50-builtins.js, 60-methods.js | what the emitter is allowed to call |
70-host.js, 80-glue.js | stdout, exit status, the host boundary |
85-js.js | the use JS interop surface |
86-async.js, 87-supply.js | promises and supplies over the event loop |
90-regex.js | the regex engine, again |
tools/js/gen-rt-src.raku bakes that into src/JsRuntimeSrc.cpp, so a binary writes the exact runtime it was built with rather than whatever happens to be on disk — the same scheme the grammar shim uses. It goes in as an array of raw-string chunks under 15 KB each, because MSVC caps a single string literal at 16 KB and a 200 KB literal does not compile there at all.
That table is the honest cost of the chapter's opening. 90-regex.js is a second regex engine. 05-gb-tables.js is a second copy of the grapheme data. Every one of those files is a place where the two implementations can drift, and the reason the next section exists.
Three outputs, and a second tier
rakupp --target=js prog.raku -o prog.js # program + rakupp-rt.js beside it
rakupp --target=js --standalone prog.raku -o p.js # one file, runtime inlined
rakupp --target=js --module lib.raku -o lib.js # an ES module, plus lib.d.ts
rakupp --target=js --runtime -o rakupp-rt.js # the runtime alone
The sidecar form is 792 bytes of program against a 374 KB runtime; --standalone is that same runtime inlined, so the file is 374 KB whatever the program. For a web page that is one cached asset and many small programs, which is why the sidecar is the default.
Anything outside the core is refused with the line and a reason:
$ rakupp --target=js prog.raku -o prog.js
note: concurrency/process types (Proc::Async) — P4 of the plan — outside the
JavaScript core; --fallback=wasm runs it on the WebAssembly engine instead
exit 5, no file written
That last clause is the second tier. --fallback=wasm emits a 2 KB wrapper that loads the interpreter compiled to WebAssembly and runs the embedded source through it — --exe-info reads back "mode":"js-wasm". So there are two ways to reach a browser and they are complements rather than rivals: the transpiler is fast and refuses things, the WebAssembly engine accepts everything and carries the whole interpreter. Chapter 31 is about building the second one.
Keeping two runtimes honest
With one runtime, agreement is structural: --exe calls applyArith and so does the interpreter, so they cannot disagree. Here it has to be measured, and t/js/run.raku is what measures it.
Every program in t/regression/ and examples/ is transpiled with --standalone, run under Node, and compared with the same binary interpreting the same file — stdout, stderr and exit status, byte for byte. The interpreter is the oracle, not a golden file, which is the important choice: a golden records what the output was, and an oracle records what it should be. The report has three numbers — in core and agreeing, refused with a histogram of reasons, and disagreeing, which must be zero. The refusal histogram is the work queue.
The gate also checks two things that are not about any program: that src/JsRuntimeSrc.cpp is current with src/js-rt/, and that every builtin the emitter is willing to call exists in the runtime. Four interop goldens under t/js/interop/ cover use JS, where the interpreter cannot be the oracle because there is no DOM on this side — they run against a stand-in and carry their own expected output.
Where it diverges
Six differences survive that discipline, and they are all the same shape: places where JavaScript's own semantics are visible through the runtime.
| interpreter | Node | |
|---|---|---|
printf("%.2f", 0.125) | 0.12 | 0.13 — JavaScript's tie rounding |
(0.1e0).Rat | 0.1 | 0.100000000000000006 |
(2e60).Int | the exact integer | 2e+60 — past 2⁵³ it stays a float |
"a\r\nb".chars | 3 | 4 — CRLF on the ASCII fast path |
lt, leg, cmp, sort, hash .gist | codepoint order | UTF-16 code-unit order |
say of an unhandled Failure | throws, exit 1 | prints (HANDLED) … and continues |
The string ordering one is the least obvious and the most likely to bite: a character above the BMP is a surrogate pair in JavaScript, so ff sorts before 𝕏 here and after it there.
Four claims about the core are also narrower than they look. Multi dispatch ranks by arity, type, literal and where — but not by sigil, so an untyped @ candidate does not beat an untyped $ one. A scalar's type constraint is not enforced at all: my Int $x = 'no' is accepted. Assigning Nil leaves Nil rather than the declared type's default. And of the match adverbs, :nth(n) is off by one and :x(n) is ignored.
Speed
Node starts in about 20 ms against the interpreter's 2, so a small program is slower under JavaScript and a program that computes is faster. On the benchmark machine, wall clock including start-up:
| kernel | interpreter | --target=js under Node |
|---|---|---|
| fib(29) | 298 ms | 82 ms |
streq (1M eq) | 233 ms | 69 ms |
| loopsum (1M) | 86 ms | 36 ms |
Those are honest numbers and they flatter the wrong thing. What the JIT is beating is the tree-walker, not the native back end — --exe on the same kernels is faster than both. The reason to reach for --target=js is that the program has to run somewhere there is no binary.
Honest limitations
- Two runtimes drift, and only a gate says so. Every fragment in
src/js-rt/duplicates something the C++ runtime already does. The corpus gate is the only thing standing between that and a silent disagreement, and it can only compare the programs it is given. - The refusal list is the plan's work queue, not a boundary. Concurrency, process types,
nqp::ops,EVALand NativeCall are refused with a message naming the phase that would add them;--fallback=wasmis the answer for a program that needs one today. - The divergences above are not bugs the gate can catch. Each is a case where the honest translation of a Raku rule into JavaScript is the divergence, and closing them means writing more runtime — which is the cost this back end exists to trade away.