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, andconsole.debug- Deno-like filesystem APIs such as
Deno.readTextFileandDeno.writeTextFile - a basic
fetch pathhelpersprocesshelpers- 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, permissionsjsraft-cli: command-line interfacejsraft-bundler: minimal bundling and source map outputjsraft-lint: regex-based lint rulesjsraft-fmt: formatter scaffoldingjsraft-pkg: npm package metadata, install, lockfile, andnode_modulessupport
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 testThis 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-consoleno-debuggerno-alerteqeqeqno-varno-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_modulesentries
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.binmanifest.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:
- Run the entry file once.
- Load the module graph.
- Watch every file in that graph.
- On change, debounce events and re-run the entry file.
- Rebuild the watched graph because imports may have changed.
The CLI surface is simple:
jsraft run src/index.js --watchThis 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 ./pluginsPlugins 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 testIt 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.okassert.equalassert.strictEqualassert.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_parseroxc_semanticoxc_transformeroxc_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.jsonexportspackage.jsonmodulepackage.jsonmainpackage.jsonbrowser- 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:
methodurlpathheadersbody
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 --secureThen permissions can be added back explicitly:
jsraft run app.js --secure --allow-read --allow-netFilesystem 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 cleandoctor 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 --allThe 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
.tsand.tsxpath detectionpackage.jsonmodule/mainresolutionexportsresolution- 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 --workspaceThese 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.