jsvm evaluates one JavaScript file in a fresh V8 isolate, prints the result, and exits. It packages a stripped V8 build, host bindings, and a custom startup snapshot into one static PIE binary.
The final Docker image contains that binary and nothing else. There is no Node.js runtime, shell, shared library, package loader, or separate snapshot file.
Note
The WebAssembly-enabled V8 14.0.126 build is compiled and smoke-tested. It produced a 28.7 MB binary, a 298 KB custom snapshot, and a 28.7 MB scratch image.
| Property | What it gives you |
|---|---|
| One static binary | Copy /jsvm into a minimal image and run it without runtime files. |
| Embedded startup snapshot | Bake shared JavaScript helpers into the heap at build time. |
| Fresh isolate per run | Discard all JavaScript state when the process exits. |
| Runtime limits | Set a wall-clock timeout and V8 old-space ceiling from the CLI. |
| Persistent code cache | Reuse compiled code for a recurring script. |
| Tiny host surface | Start with print() and add only the native functions your workload needs. |
jsvm fits short-lived transforms, validation jobs, isolated compute, and JavaScript bundles that need V8 semantics without Node's startup and deployment footprint.
Build the image from this checkout:
docker build -t jsvm .The examples use Docker syntax. Podman works with the same commands after replacing docker with podman.
The first build fetches and compiles V8. On the verified 16-core build it took 25–40 minutes and used about 15 GB of scratch disk; Docker caches that heavy stage.
Evaluate an expression from standard input:
$ printf '1 + 1\n' | docker run --rm -i jsvm
2Emit machine-readable output:
$ printf 'jsvm.emit({ready: true})\n' | docker run --rm -i jsvm --quiet
{"ready":true}Run a local file through standard input while giving V8 a stable script origin:
docker run --rm -i jsvm \
--origin=https://example.invalid/app.js \
--quiet < app.jsThe build tool evaluates src/preload.js once and serializes its context. The linker embeds that blob in .rodata, next to the runner and the feature-stripped V8 monolith.
Each invocation creates a fresh isolate from the embedded context, compiles one classic script, runs its microtasks, writes output, and disposes the process.
Pass a script path or omit it to read standard input:
build/jsvm job.js
printf '40 + 2\n' | build/jsvmjsvm prints a non-undefined completion value unless you pass --quiet. Object completion values use JavaScript string conversion, so use jsvm.emit() when a caller expects JSON.
const result = { answer: 42 };
jsvm.emit(result);Promise microtasks run before exit:
$ printf 'Promise.resolve(42).then(jsvm.emit)\n' | build/jsvm --quiet
42The startup snapshot exposes four helpers:
| Global | Behavior |
|---|---|
print(...values) |
Converts values to strings, joins them with spaces, and writes one line. |
jsvm.emit(value) |
Writes JSON.stringify(value) followed by a newline. |
jsvm.assert(condition, message) |
Throws an Error when the condition is false. |
clone(value) |
Clones JSON-compatible data through stringify and parse. |
src/preload.js defines the JavaScript helpers. src/bindings.cc installs print() as the only native host function.
V8 includes the script origin in Error().stack. A script that reads its own stack therefore consumes the origin as program input.
Use --origin=NAME when invocation paths differ across machines, containers, or input modes:
build/jsvm --origin=https://geo.captcha-delivery.com/interstitial/ bundle.jsOne 250 KB obfuscated anti-bot bundle reads its own stack inside module 872. Identical source produced three different values when only its origin changed:
| V8 script origin | Observed i |
|---|---|
out/bundle.js |
292825 |
<stdin> |
291624 |
https://geo.captcha-delivery.com/interstitial/ |
299580 |
These values belong to that workload, not to a benchmark. They show why reproducible callers should pin an origin instead of inheriting a file path or <stdin>.
--cache=PATH reads a V8 code cache when the file exists and writes one after a successful uncached run. V8 rejects stale cache data and jsvm replaces it after compiling the source.
build/jsvm --cache=bundle.v8cache --origin=/app/bundle.js bundle.js
build/jsvm --cache=bundle.v8cache --origin=/app/bundle.js bundle.jsKeep the source, V8 build, relevant V8 flags, and origin stable between runs. Treat cache files as disposable build artifacts.
jsvm [options] [script.js]
--timeout=MS wall-clock limit, 0 disables (default: 5000)
--heap=MB V8 old-space ceiling (default: 128)
--cache=PATH read or write a persistent code cache
--origin=NAME resource name exposed through Error().stack
--quiet suppress the script completion value
--v8=FLAG pass one flag to V8; repeat for more flags
-h, --help print usage
With no script.js, jsvm reads standard input. The default origin is the script path or <stdin>.
| Exit | Meaning |
|---|---|
0 |
The script completed. |
1 |
Compilation or execution threw an uncaught exception. |
2 |
The CLI input or script file was invalid. |
70 |
V8 rejected the embedded snapshot. Rebuild the binary and snapshot together. |
124 |
The wall-clock watchdog terminated execution. |
125 |
An out-of-memory path reached jsvm's V8 OOM handler. See the heap-limit note below. |
Warning
On the pinned V8 build, exhausting the configured heap prints V8's GC report and aborts before the OOM handler runs. The ceiling works, but callers must not rely on exit 125 to detect it.
Some --v8 flags affect V8's snapshot hash. If a flag causes rejection, add it to the shared kV8Flags value in src/bindings.cc and rebuild both artifacts.
Build only the expensive V8 stage when you want to prewarm the local cache:
docker build --target v8src -t jsvm-v8:14.0.126 .
docker build -t jsvm:latest .The final stage runs as numeric UID/GID 65532:65532 on scratch. It targets Linux x86-64.
Build against a V8 tree that already contains out/rel/obj/libv8_monolith.a:
make V8_ROOT=/path/to/v8 verify-abi
make V8_ROOT=/path/to/v8 -j"$(nproc)"
make V8_ROOT=/path/to/v8 checkmake verify-abi reads ABI-affecting defines from the actual GN output. Run it after every V8 change.
The verified WebAssembly-enabled V8 14.0.126 build produced:
| Artifact | Size |
|---|---|
libv8_monolith.a |
125 MB |
stripped jsvm static PIE |
28.7 MB |
| custom startup snapshot | 298 KB |
final scratch image |
28.7 MB |
1 + 1 process launches averaged 3.7 ms across three 200-run rounds. Node.js 25 averaged 14.7–17.0 ms across three 100-run rounds on the same host. Measure your workload before setting latency budgets.
Put pure JavaScript that every job needs in src/preload.js. Parsers, validators, polyfills, and immutable lookup data are good candidates.
The snapshot freezes build-time values. Do not bake in Date.now(), Math.random(), open handles, pending work, or state that must differ between invocations.
- Write the V8 callback in
src/bindings.cc. - Append its pointer to
kExternalReferences. Do not reorder existing entries. - Register it in
InstallGlobals. - Rebuild so the snapshot and runner share the new reference table.
An old snapshot can call the wrong native address when the external-reference order changes. jsvm therefore builds the blob and final binary as one unit.
jsvm runs classic scripts, not ES modules. It has no module loader, timers, fetch, filesystem API, process, CommonJS globals, or browser DOM.
The build removes Intl and Temporal to reduce size and external data requirements. WebAssembly stays enabled because the target collector hashes the return value of its jbFdNf WASM export.
Disabling WebAssembly makes that probe emit its -1 sentinel and causes DataDome to reject the payload. The feature adds about 6 MB to the stripped binary.
--single-threaded disables background compilation and concurrent marking. That favors jobs under roughly 100 ms; long-running or hot workloads may perform better with V8's default platform.
Each process gets a fresh V8 isolate, and the JavaScript host surface exposes no file or network access. The Docker image adds a non-root user and contains no shell or userland tools.
V8's internal sandbox and an isolate do not form a complete security boundary for hostile native exploits. Run untrusted scripts inside a hardened container or VM with OS-level CPU, memory, syscall, filesystem, and network controls.
The --heap option constrains V8's managed heap, not total process memory. V8 also reserves a large virtual address range, so test cgroup, seccomp, and address-space limits on the target runtime.
jsvm chooses isolate-per-process. The verified build paid about 3 ms to start, then discarded all state at exit.
For high-throughput trusted jobs, keep one isolate per worker thread and create a context from the snapshot per job. Context creation can cut overhead, but contexts share a heap and are not a security boundary.
| Path | Role |
|---|---|
Dockerfile |
Fetches V8, builds the embedder, runs smoke tests, and creates the scratch image. |
args.gn |
Defines the stripped V8 monolith and ABI-affecting build flags. |
Makefile |
Builds the snapshot tool, embeds the blob, links jsvm, and runs checks. |
src/jsvm.cc |
Parses the CLI, creates the isolate, enforces limits, runs the script, and handles code cache. |
src/bindings.cc |
Defines native globals, external references, file helpers, and exception reporting. |
src/preload.js |
Defines JavaScript globals serialized into the startup snapshot. |
src/mksnapshot_tool.cc |
Evaluates the preload and serializes the default context. |
src/snapshot_blob.S |
Embeds snapshot.bin directly into the final binary's read-only data. |
V8 upgrade notes
With this standalone monolith, enabling Temporal leaves an unresolved temporal_rs_Instant_epoch_milliseconds symbol. Linking the Rust temporal_capi archive is possible but defeats this build's size goal.
WebAssembly's optimized-builtins path expects tools/builtins-pgo/profiles/x64.profile. The standalone shallow checkout lacks that profile, so v8_enable_builtins_optimization=false is required.
Pointer compression, the V8 sandbox, and Smi layout change public struct layouts. The Makefile derives matching -D values through gn desc; hand-written values can produce silent heap corruption.
V8 exposes standard-library types in public headers. Both V8 and the embedder use system headers here, with use_custom_libcxx=false and use_sysroot=false.
The official build emits ThinLTO bitcode into libv8_monolith.a. The Makefile uses V8's bundled Clang and lld; GNU ld cannot link that archive.
v8::SnapshotCreator and v8::ScriptOrigin have changed signatures across V8 releases. Search the VERSION RISK comments and check the matching V8 headers before bumping the pin.