Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A JavaScript file passes through a compact V8 isolate with an embedded startup snapshot and becomes structured output.

jsvm

A small, fast V8 runner for one-shot JavaScript.

Build verified V8 14.0.126 28.7 MB static binary C++20 Linux x86-64

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.

Why jsvm?

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.

Quick start

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
2

Emit 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.js

Execution model

The build turns V8, host bindings, and preload JavaScript into one static binary. Each invocation creates a fresh isolate, runs one script, and exits.

The 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.

Use it

Input and output

Pass a script path or omit it to read standard input:

build/jsvm job.js
printf '40 + 2\n' | build/jsvm

jsvm 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
42

Runtime API

The 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.

Pin the script origin

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.js

One 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>.

Reuse compiled code

--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.js

Keep the source, V8 build, relevant V8 flags, and origin stable between runs. Treat cache files as disposable build artifacts.

CLI reference

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

Docker

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.

Existing V8 checkout

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 check

make verify-abi reads ABI-affecting defines from the actual GN output. Run it after every V8 change.

Verified footprint

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.

Customize the runtime

Bake in JavaScript

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.

Add a native host function

  1. Write the V8 callback in src/bindings.cc.
  2. Append its pointer to kExternalReferences. Do not reorder existing entries.
  3. Register it in InstallGlobals.
  4. 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.

Deliberate limits

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.

Isolation and untrusted code

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.

Scaling beyond one shot

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.

Project map

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

Keep Temporal disabled

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.

Disable optimized built-ins

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.

Extract ABI defines

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.

Keep one standard library ABI

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.

Use V8's Clang and lld

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.

Check snapshot APIs

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.

About

A small, fast V8 runner for one-shot JavaScript.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages