A Minimal JavaScript Runtime and Toolchain in Rust

I started JSRaft with a simple but ambitious question: what would it look like to build a small JavaScript runtime and developer toolchain in Rust, without immediately reaching for the huge machinery that powers production runtimes like Node.js, Deno, or Bun?

I did not want to clone any one runtime exactly. I wanted something smaller and more inspectable. I wanted a project where I could understand the runtime loop, module loading, package resolution, formatting, linting, testing, plugin loading, bytecode caching, and CLI design from top to bottom. The result became JSRaft: a minimal JavaScript runtime, compiler toolchain, package ecosystem, and CLI built around Rust, QuickJS, Oxc, and Tokio.

This article is the story of how I built it phase by phase, what tradeoffs I made, what changed along the way, and where the architecture is heading next.

Starting With a Small Runtime Core

The first goal was to get JavaScript code executing from Rust. I chose QuickJS through rquickjs because it gives me a compact embeddable JavaScript engine with a Rust API. QuickJS is not V8, and that is exactly why it made sense for the first version. I wanted the runtime to be small enough to reason about.

The central API became JsRuntime in jsraft-core:

let runtime = JsRuntime::new(RuntimeConfig::default());
runtime.run_file(Path::new("src/index.js")).await?;

Internally, JsRuntime stores a RuntimeConfig and a ModuleLoader. It does not hold a long-lived QuickJS runtime for normal file execution. Instead, each run_file() call creates a fresh AsyncRuntime and AsyncContext, registers extensions, and executes the entry file.

That decision kept the lifecycle simple. Each run starts from a clean runtime state, which is especially useful for watch mode, tests, permissions, and future isolation work. The REPL is the exception: it uses a persistent ReplSession because interactive evaluation needs state to survive between inputs.

At this stage, I added the first built-in APIs:

  • console.log, console.error, console.warn, console.info, and console.debug
  • Deno-like filesystem APIs such as Deno.readTextFile and Deno.writeTextFile
  • a basic fetch
  • path helpers
  • process helpers
  • timer stubs

The first version was deliberately pragmatic. The runtime could execute JavaScript files, but module support was still simple. I loaded a dependency graph, stripped ESM syntax, concatenated sources, and evaluated the result. This was not correct JavaScript module semantics, but it was enough to bootstrap the rest of the toolchain and expose where the architecture needed to evolve.

Organizing the Workspace

I split the repository into a Rust workspace with focused crates:

  • jsraft-core: runtime, modules, extensions, plugins, transforms, snapshots, permissions
  • jsraft-cli: command-line interface
  • jsraft-bundler: minimal bundling and source map output
  • jsraft-lint: regex-based lint rules
  • jsraft-fmt: formatter scaffolding
  • jsraft-pkg: npm package metadata, install, lockfile, and node_modules support

This workspace structure mattered more than I expected. It forced a clean boundary between runtime concerns and CLI orchestration. It also made it easier to add CI later because every capability had a crate-level home.

The CLI was designed around cargo-style commands:

jsraft run app.js
jsraft build
jsraft lint
jsraft fmt
jsraft install
jsraft repl
jsraft test

This shape kept the tool familiar. I did not want the CLI to be clever. I wanted it to be predictable.

Building the First Tooling Layer

After the runtime could execute code, I built the surrounding developer tools.

The bundler started as a minimal graph collector. It resolves imports, reads files, strips simple ESM syntax, concatenates sources, and writes a bundle. Later I added deterministic ordering and basic source map output. It is not a production bundler yet, but it gives the project a place to grow into deeper Oxc integration.

The linter began as a regex-based engine with rules such as:

  • no-console
  • no-debugger
  • no-alert
  • eqeqeq
  • no-var
  • no-unused-vars

The formatter started even smaller: whitespace normalization, line-ending normalization, trailing whitespace cleanup, and final newline handling.

I was careful not to overbuild these early systems. The long-term direction is AST-based tooling with Oxc, but the immediate goal was to create working CLI surfaces and testable crate boundaries.

Adding a Package Manager Skeleton

The package manager was another early pillar. I wanted JSRaft to understand npm-style packages without depending on Node.js itself.

The first version of jsraft-pkg can:

  • read jsraft.toml
  • query npm registry metadata
  • download tarballs
  • extract packages
  • create a lockfile
  • create node_modules entries

It is still intentionally limited. Semver handling is basic. Transitive dependency installation is not complete. But it gave the runtime and resolver something concrete to target later.

That became important once I implemented package-aware runtime resolution.

Bytecode Caching: My Version of Snapshots

One of the most interesting phases was snapshot support. Since JSRaft currently uses QuickJS, I could not implement V8-style heap snapshots. QuickJS does not expose the same isolate snapshot model that V8 does.

Instead, I built the closest useful thing: bytecode caching for plain scripts.

The cache lives under .jsraft/cache by default. For a module graph, JSRaft computes a content hash, compiles the script source to QuickJS bytecode through raw QuickJS FFI, and writes:

  • bytecode.bin
  • manifest.json

On the next run, if the graph hash matches, JSRaft loads and executes the cached bytecode instead of parsing the script source again.

This taught me a lot about the difference between script bytecode and module bytecode. The current bytecode cache is intentionally limited to the non-ESM script path. Once the runtime moved to real ESM semantics, module execution used QuickJS modules instead of script bytecode. That means ESM bytecode caching remains future work.

The important part was that the cache became real, measurable infrastructure without pretending to be a V8 heap snapshot.

Watch Mode

Once bytecode caching existed, watch mode was the natural next feature.

I used the notify crate to watch the entry file and its dependency graph. The flow is:

  1. Run the entry file once.
  2. Load the module graph.
  3. Watch every file in that graph.
  4. On change, debounce events and re-run the entry file.
  5. Rebuild the watched graph because imports may have changed.

The CLI surface is simple:

jsraft run src/index.js --watch

This made the runtime feel much more like a real development tool. It also validated the decision to create a fresh QuickJS runtime per run_file() call. Re-running on change does not need cleanup of old JS state; it just starts again.

Plugins

I wanted JSRaft to be extensible without immediately building a native plugin ABI. The first plugin system is JavaScript-based.

By default, JSRaft discovers plugin files in plugins/, or the user can pass custom directories:

jsraft run app.js --plugins-dir ./plugins

Plugins run before the entry file. They get a small global API:

JSRaft.registerPlugin("my-plugin");
globalThis.myFeature = "available to app code";

I originally experimented with a Rust-backed plugin registry that captured JS objects in Rust closures, but QuickJS object lifetime cleanup exposed why that was a bad idea. The fix was to keep plugin registry state owned entirely by JavaScript through a bootstrap script. That avoided holding JS values across runtime teardown.

That was a useful reminder: embedding a JS engine means respecting its ownership and lifetime model, not just wrapping everything in Rust closures.

Test Runner

The next ecosystem feature was a built-in test runner:

jsraft test

It discovers:

  • *.test.js
  • *.spec.js
  • *.test.mjs
  • *.spec.mjs

It provides:

test("math works", () => {
  assert.strictEqual(1 + 1, 2);
});

and a small assertion API:

  • assert.ok
  • assert.equal
  • assert.strictEqual
  • assert.throws

Each test file runs in a fresh QuickJS context. Like much of JSRaft, the first test runner is intentionally minimal. It is synchronous and does not yet have hooks, filters, async test support, snapshots, or coverage. But it gives the project a native testing story and a foundation for CI.

Moving From Fake ESM to Real ESM

The biggest architectural turning point was replacing the concat-and-strip ESM approximation with real QuickJS module loading.

I added QuickJsModuleResolver and QuickJsModuleLoader, both backed by JSRaft’s ModuleLoader. When a loaded graph contains import or export, the runtime now uses:

rquickjs::Module::evaluate(...)

instead of the legacy script evaluation path.

This unlocked correct semantics for:

  • named imports
  • default imports
  • re-exports
  • nested relative imports
  • JSON default imports
  • package imports

Plain scripts still use the old script path, which means bytecode caching continues to work for them.

This phase changed the character of the runtime. Before it, modules were something JSRaft imitated. After it, modules became something QuickJS actually executed.

TypeScript Transformation With Oxc

Once real modules were in place, TypeScript support became the next bottleneck. The runtime config already had a typescript flag, but the runtime was not actually transforming .ts files.

I added a dedicated transform module powered by Oxc:

  • oxc_parser
  • oxc_semantic
  • oxc_transformer
  • oxc_codegen

The core function is:

transform_typescript(path, source)

For .ts and .tsx, it parses the source, builds semantic information, runs the transformer, and emits JavaScript. For .js, it returns the input unchanged.

This transform is used in both:

  • the script execution path
  • the ESM module loader path

That means TypeScript files can now be entry points and dependencies:

export type User = { name: string };
export const user: User = { name: "Ada" };

JSRaft strips the type layer before handing the source to QuickJS.

Package-Aware Runtime Resolution

The next gap was package resolution. The original resolver checked node_modules/<specifier> directly. That is not enough for real npm packages.

I expanded runtime resolution to understand:

  • package directories
  • scoped packages such as @scope/pkg
  • package subpaths such as pkg/feature
  • package.json exports
  • package.json module
  • package.json main
  • package.json browser
  • index fallbacks

This is still not a complete implementation of Node’s conditional exports algorithm, but it supports common package shapes and gives JSRaft a much better runtime story for installed dependencies.

For example, these forms now work:

import { value } from "pkg";
import { feature } from "pkg/feature";
import { scoped } from "@scope/pkg";

This was a major step toward making the package manager and runtime feel connected.

HTTP Server APIs

I then added a minimal HTTP server API to the networking extension.

The runtime now exposes:

JSRaft.serve((request) => {
  return {
    status: 200,
    contentType: "application/json",
    body: JSON.stringify({ ok: true }),
  };
}, { port: 3000 });

and a Deno-compatible shape:

Deno.serve({ port: 3000 }, (request) => "hello");

The first implementation is intentionally blocking and based on std::net::TcpListener. That is not the final server architecture, but it makes server templates real instead of aspirational.

The request object includes:

  • method
  • url
  • path
  • headers
  • body

The response can be a string or an object with status, body, and contentType.

This feature also forced me to think harder about permissions.

Permissions and Security

Initially, every built-in API was allow-by-default. That is convenient but not safe as the runtime grows. I added a RuntimePermissions model with coarse-grained booleans:

  • read
  • write
  • net
  • env
  • process

The default remains open for compatibility. But the CLI now has a deny-by-default mode:

jsraft run app.js --secure

Then permissions can be added back explicitly:

jsraft run app.js --secure --allow-read --allow-net

Filesystem APIs check read and write. Network APIs check net. Environment exposure checks env. Process mutation APIs check process.

This is not yet path-scoped or host-scoped, but it establishes the security boundary in the API design.

Developer Experience: Doctor and Clean

I added two small commands that tend to pay off quickly in developer tools:

jsraft doctor
jsraft clean

doctor prints project and runtime diagnostics, including whether key files and directories exist, runtime backend information, TypeScript transform status, cache status, and permission mode guidance.

clean removes generated artifacts:

jsraft clean
jsraft clean --all

The default removes .jsraft/cache; --all also removes dist/ and build/.

Neither command is complicated, but both make the CLI feel more complete.

Adding Tests

Before setting up CI, I added automated Rust tests around the highest-risk paths.

In jsraft-core, I added tests for:

  • TypeScript transform behavior
  • JavaScript passthrough behavior
  • .ts and .tsx path detection
  • package.json module / main resolution
  • exports resolution
  • subpath exports
  • scoped package resolution
  • TypeScript graph transforms

In jsraft-cli, I added tests for:

  • default open permission mode
  • secure permission flag mapping
  • clean command removal behavior
  • missing clean path behavior

Then I verified:

cargo test --workspace

These tests are not exhaustive, but they cover the areas where regressions would be most expensive.

What I Learned

The biggest lesson from this project is that building a runtime is less about one big engine decision and more about hundreds of small boundary decisions.

Some examples:

  • Should normal execution reuse a JS context or start fresh each time?
  • Should plugins be Rust-owned or JS-owned?
  • Should TypeScript be transformed in the loader or before graph loading?
  • Should permissions be default-open or default-closed?
  • Should the first server API be async or blocking?
  • Should package resolution aim for full Node compatibility immediately or support common cases first?

For JSRaft, I repeatedly chose the smaller correct step. That meant some systems started as MVPs, but they started in the right place architecturally.

The project is still not a production replacement for Node, Deno, or Bun. It is not trying to be one yet. It is a compact runtime and toolchain that makes the moving parts visible.

Where this goes next

The next technical milestones are clear:

  • async HTTP server internals
  • real timer/event-loop integration
  • ESM bytecode caching
  • deeper Node-compatible package exports resolution
  • transitive dependency installation
  • path-scoped and host-scoped permissions
  • async test support
  • AST-powered linting and formatting
  • richer diagnostics and source maps
  • optional V8 backend experiments

The foundation is now in place: runtime, modules, TypeScript, package resolution, plugins, tests, permissions, docs, and CI/CD.

That is the point where JSRaft starts to feel less like an experiment and more like a real platform.

And that was the goal from the beginning: not to build everything at once, but to build a small, understandable JavaScript ecosystem in Rust one correct layer at a time.