Skip to content

A small, embeddable and fast JavaScript engine

staticjs is a JavaScript engine for a pure, total subset of the language: every program it accepts ends, and gives the value Node gives or a fault.

js
[
  { id: 1, open: true, total: 120, due: "2024-02-10" },
  { id: 2, open: false, total: 80, due: "2024-01-10" },
  { id: 3, open: true, total: 300, due: "2024-02-25" },
]
  .filter((order) => order.open)
  .toSorted((a, b) => b.total - a.total)
  .map((order) => ({
    id: order.id,
    late: Temporal.PlainDate.from(order.due)
      .until("2024-03-01").days,
  }))
[{"id":3,"late":5},{"id":1,"late":20}]array, 78 fuel
Edit the program to filter and sort orders; the result and fuel used update.

Overview

Like JavaScript, staticjs has numbers, BigInt, strings, template literals, arrays, objects, Set, Map, Temporal and Date, arrow functions, destructuring with defaults and rest, spread, optional chaining and the usual operators. A program is one expression, or a module of imports, const declarations and exports. Unlike JavaScript, it has no loops and no recursion.

staticjs leaves out loops (for, while, do), recursion, classes, async/await and promises, generators, try/catch/throw, var, labels, switch, eval, this, prototypes, getters and setters, and I/O. A function may change only a value it owns, such as an array it made; shared values never change.

Total
Every evaluation ends: the evaluator runs on fuel the host pushes, and when the fuel runs out it pauses and resumes from where it stopped, inside a callback included.
Exact
A program staticjs accepts is valid JavaScript. Where JavaScript would coerce, compare objects by identity or look on a prototype, it faults with a line, a column and a fixed message.
Hermetic
A program reads only the globals the host passes in by name, calls only the native C functions it adds and imports only the modules it provides. It has no file system, network, clock or randomness of its own.
Deterministic
The same program with the same inputs gives the same value and uses the same fuel on every machine.
Bounded
Every byte comes from the allocator the host gives, and every construct has a limit set by the host.

What staticjs gives for four valid JavaScript programs:

[1, 2] === [1, 2]     // fault: === on two objects compares identity, which staticjs does not have
"5" * 2               // fault: arithmetic in staticjs is on numbers
({}).a.b              // fault: cannot read a property of undefined
() => { for (;;) {} } // refused when compiled: staticjs has no loops

Install

staticjs is freestanding C11: it needs no C library, only stdint.h, stddef.h and stdbool.h, and has one public header, staticjs.h.

Performance

On a 4-core x86_64 machine, evaluating a compiled small expression from C takes about 0.14 µs, and compiling it takes 6.4 µs. Node’s vm.Script.runInContext takes 1.4 µs for the same expression, and creating a fresh V8 context takes 330 µs. Over 33 evaluation cases, Node with its JIT takes 0.43 of staticjs’s time, so Node’s JIT is faster. Node with --jitless takes about as long as staticjs, and QuickJS-NG compiled to WebAssembly takes 4.8 times as long. Benchmark report

Live measurement

staticjs and QuickJS-NG, both compiled to WebAssembly, run eight programs in a web worker, and a row is timed only when both engines return the same result.

Waiting to start.

ProgramstaticjsQuickJS-NGstaticjsQuickJS-NG
one expression
10,000 orders
5,000 rows
10,000 numbers
100,000 numbers
2,000 strings
2,000 addresses
1,000 records

Each time is the median of five samples of at least 40 ms. staticjs is called from JavaScript once per run, as a host calls the C API. QuickJS-NG (quickjs-wasi) loops inside its engine and is called once per sample.

Limitations

  • A program written for Node usually needs rewriting with map, filter and reduce. What the overview lists as left out is refused when the program is compiled, as an unsupported error.
  • Valid JavaScript that coerces or compares objects by identity faults instead of running, as the examples in the overview show.
  • Temporal supports the ISO calendar with UTC and fixed offsets. IANA time zones are not available. Date is UTC only.
  • Regular expressions run in linear time, without backtracking. Lookahead, lookbehind, backreferences, \p{...} and the v flag are not supported.
  • The WebAssembly module (about 950 KiB) is larger than QuickJS’s (about 620 KiB).
  • staticjs is at version 0.9.0, and the API may change.

Comparison

staticjs is basically CEL with JavaScript syntax and user functions.

staticjsQuickJSCELStarlark
SyntaxSubset of JavaScriptAll of JavaScript (ES2025)C-like syntax of its ownDialect of Python
Functions in programsArrow functionsAll JavaScript functionsNone; the host provides themdef functions
LoopsNoneAll JavaScript loopsNone; macros such as all, map, filterfor over finite sequences
Every evaluation endsYes; fuel the host pushesNo; the host’s interrupt handler stops itYes; not Turing completeYes; no recursion, no unbounded loops
What a program can reachValues and modules the host passes in—Variables and functions the host providesHermetic
DeterminismSame value and fuel on every machine——Deterministic
Memory limitsHost’s allocator; limits set by the hostA memory limit on the runtime——
Coercion and identityFaults on bothFollows JavaScript: "5" * 2 is 10——
ImplementationsFreestanding C11, WebAssemblyA few C files; QuickJS-NG forkGo, Java, C++Go, Java, Rust
  • CEL also has type checking and formal verification.
  • Starlark also runs threads in parallel over frozen shared data.

How it works

staticjsmemory from the host’s allocator
hostC or JavaScript
parserbytes, in any chunks
compilerevents → bytecode
programbytecode, shapes
evaluatorruns on fuel
writervalue → source text
byteseventsfinishreadfuelvaluetext, in pieces
stream / queue sync call shared memory · no copy

staticjs has four stages: the parser, the compiler, the evaluator and the writer. A caller pushes into each one: bytes into the parser, which sends events to a sink, operands before their operator; the compiler is that sink and makes bytecode as the events arrive; fuel into the evaluator; buffer space into the writer. None blocks or calls back for more input, so a host can stream source in chunks, and there is no recursion anywhere in the C code.

The compiler also tags functions whose shape a built-in can run in C, such as x => x.key or (a, b) => a.k - b.k. A tagged function saves fuel and never changes a value. A host can pause an evaluation and read its frames, for example the program, map at element 2 of 4, and the callback, each with a position.

Frames of an evaluation

The program below runs in this page one unit of fuel at a time, and after each unit the page reads the evaluation’s frames. The comparator (a, b) => a - b has a shape that toSorted runs in C, so it opens no frame of its own.

[[3, 1], [2]].map((row) => row.toSorted((a, b) => a - b).map((n) => n * 10))

Running the program…

The explorer runs staticjs.wasm in the browser and shows the parser’s events, the compiler’s functions and shapes with the fuel used with and without them, the evaluator one unit of fuel at a time with its frames, and the writer’s output in 16-byte pieces.

Open the explorer

Values

Every value is a 16-byte sjs_value: its kind, a boolean, and a number or a pointer to a reference-counted heap object. Copying the struct does not retain the object.

sjs_value
04
+0
kinduint8_t
internaluint8_t
reserveduint16_t
booleanuint32_t
+8
asdouble | sjs_heap *
sizeof = 16 B
number or pointer

Documentation