Scope & non-goals

esrun is a secure, standards-based runtime for running server-side JavaScript — and nothing more. Its scope is deliberately narrow. Knowing what it does not do is as important as knowing what it does.

Some of that narrowness is a division of labour, not an absence: TypeScript, bundling, testing, watching and debugging live in a second binary, esdev, which never reaches production. The rest below are durable boundaries for the whole project.

In scope

Running standard ES Modules on V8 with a Web-standard API surface, deny-by-default capabilities, a sandboxed module system, and host functionality exposed through runtime: modules — as a server runtime, shipped as the esrun binary.

Non-goals

These are explicit, durable boundaries — not missing features awaiting implementation.

  • No Node.js compatibility. esrun is not a drop-in for Node.js. There is no node: builtin namespace, no Node.js globals (no process global, no Buffer, no require), and no npm lifecycle. Code targets Web standards plus the runtime: modules, not the Node.js API.

  • No CommonJS at runtime. Modules are real ES Modules only. There is no require(), no module.exports, and no CJS↔ESM interop; a CommonJS-only package is rejected with a clear error. It is converted at build time instead — esdev build absorbs CJS→ESM and esrun receives ordinary ESM, so a deployed artifact stays the text that was reviewed.

  • No remote module imports. import "https://..." is explicitly rejected. Load data through a runtime:net API instead, keeping code locally verified and safe from supply-chain hijacking.

  • No package installer. esrun resolves an existing node_modules tree but does not install anything. Use your package manager (npm, pnpm, bun) to populate dependencies.

  • No linter or formatter. oxlint and oxfmt are npm binaries that already do this well; a shim around them would add a version matrix for no gain.

  • No FFI or native addons. There is no foreign-function interface and no native addon ABI. The host extends the runtime through injected providers and capability-gated ops, in Rust.

  • No SharedWorker, and no data:/blob: worker URLs. The dedicated Worker ships (see Workers); the shared one exists to share a worker between documents, and there are none. A worker's URL names a file.

Not in esrun — in esdev

These are development tooling, and the binary that serves production is the one that should have none of it. An inspector port in particular is a total bypass of the capability model: attach and you own the isolate, whatever the flags said.

TypeScript & JSXesdev app.ts strips types as files load — erased, never checked
A bundleresdev build — one deployable ES module, and how CommonJS becomes runnable
A test runneresdev test — one process per file
Watch modeesdev --watch — a restart is a SIGTERM, so it drains
A debuggeresdev --inspect — and even there, only in a build that asked for it
Permission discoveryesdev --trace-permissions — prints the esrun line to deploy with

esdev shares every line that decides how a run behaves, so a program cannot behave one way there and another in production. It is not a deployment target.

One runtime, not a platform

A server runtime is the whole goal. There is no embedding API on offer: the Rust crates this is built from are not published, and running JavaScript inside somebody else's process is a different product with different goals — it is not part of this project. If you need it, open an issue and say what for; until somebody does, shipping an API nobody asked for would fix its shape before its requirements are known.

Multiple isolates under host control — the "es-vm" idea that used to be listed here as a second layer — went with it.

Workers are the part that stayed, and they ship: a Worker is a real second agent on a real thread, spawned through the same provider seam as every other reach outside the isolate.

Last updated on
Edit this page