esdev

Two binaries. esrun runs a service in production and has no development surface at all — no inspector port, no file watcher, no test discovery, no transform, and it grants nothing unless the command line says so. That narrowness is what makes --allow-listen=8080 a statement rather than a hope, and it bills your inner loop. esdev is the binary that pays it.

Shell
esdev app.ts                  # TypeScript, JSX, run
esdev --watch server.ts       # rerun on change, drain on restart
esdev test                    # run the test files
esdev build server.ts         # one deployable ES module
esdev build --lib src         # a publishable library, with its .d.ts
esdev build --lib src --dts-bundle   # ...its types linked into one file
esdev --inspect app.ts        # attach a debugger
esdev --trace-permissions app.ts   # print the esrun line to deploy with
Not a deployment target

Ship the artifact and run it under esrun. esdev has a debugger port and a file watcher in it; esrun cannot be built with either.

It never changes what the JS sees

Same prelude, same snapshot, same providers, same capability enforcement — all shared with esrun, in one crate both binaries sit on. A program cannot behave one way here and another in production. What esdev changes is everything around a run: watching, restarting, attaching, discovering, reporting, building.

The permission flags are the same ones and mean the same thing, with one deliberate difference: esdev grants everything by default, esrun grants nothing. An inner loop should not need flags to run the program you are in the middle of writing; a deployment should say what it may reach. Everything else — the names, the scope lists, the rules, the error messages — is one implementation shared by both binaries.

Shell
esdev server.ts                              # everything granted
esdev --deny-all --allow-listen=8080 server.ts   # ...or exactly esrun's grant

esdev --trace-permissions server.ts closes the gap: it prints the esrun line that grants exactly what a run reached for. See Tracing permissions.

Get it

Shell
cargo build-dev                          # → target/release/esdev
ES_RUNTIME_INSPECTOR=1 cargo build-dev   # ...with the debugger

esdev is a separate binary; the esrun install script does not place it. --inspect needs the second line — the debugger is compiled in only when the build asks for it, and a build without it says so instead of listening on nothing.

TypeScript & JSX

.ts, .tsx, .mts, .cts and .jsx are stripped to JavaScript as they load. A .js file is passed through untouched.

Shell
esdev app.ts
esdev app.tsx

Three rules fix the shape:

Types are erased, never checkedA type error is your editor's job, and tsc --noEmit's — not something on the critical path of every run. Same call Node and Bun make.
Specifiers are left exactly as writtenimport './app.ts', not ./app.js. Resolution is esrun's, unchanged; extension guessing would make the two binaries disagree about which file a program is.
.js is not reprintedRound-tripping it through a parser and printer would move every line in its stack traces for no gain.

JSX compiles to the automatic runtime, react/jsx-runtime by default. Point it elsewhere per file:

TypeScript
/** @jsxImportSource remix/ui */

For editor types, see TypeScript setup.

Watch

Shell
esdev --watch --deny-all --allow-listen=8080 server.ts

A restart drains. It is a SIGTERM, the same graceful stop production gets: the server stops accepting, answers the requests already in flight, and only then exits. Saving a file while a request is open does not drop that request. --shutdown-grace=<ms> bounds the wait, after which the process is killed.

The program runs in a child process, so nothing leaks between runs — no listening socket, no wedged isolate, no global left behind. Watched: the project root (nearest package.json) or the entry's directory, minus node_modules, .git, dist, target and .cache, and only source extensions. A program that exits leaves the watcher up.

Windows

There is no SIGTERM and no console-independent way to ask another process to stop cleanly, so a restart there kills rather than drains.

Three modules only esdev serves

Development machinery is available to the program as well as to the command line — as runtime: modules that exist under esdev and nowhere else:

Module
runtime:buildthe bundler, with a plugin system of this project's own — hooks running in your isolate
runtime:testtest() and the assertions esdev test runs
runtime:watchfile-change events, with a watch set you can grow while it runs

The first and last are here for the thing a subcommand cannot be: a framework's dev server, which bundles a route on demand, keeps the chunk in memory, serves it, and on a save drops exactly the chunks that used the changed file. The cache, the browser websocket and the route table belong to that program, so what it needs from this toolchain is an API rather than a command.

JavaScript
import { build } from "runtime:build";
import { watch } from "runtime:watch";

const bundle = await build({ input: route, plugins: [mdx()] });
const { output, watchFiles } = await bundle.generate({ codeSplitting: false });

esrun serves none of them: importing one there fails at load rather than half-working. See the bundler bridge for how a parallel bundler and a single-threaded isolate share the work.

Flags

Everything esrun takes, plus these. Every flag is --flag or --flag=value — a value is never a separate argument — and they come before the script.

Flag
--watchRerun the program when its source changes
--inspect[=<addr>]Serve the Chrome DevTools Protocol (default 127.0.0.1:9229)
--inspect-brk[=<addr>]...and stop before the first statement
--trace-permissionsPrint the permissions the run used
--install-typesAdd @opentf/esrun-types and wire up tsconfig.json
Subcommand
esdev test [filter...]Run the test files
esdev build <entry>Bundle into one ES module
esdev build --lib <srcdir>Build a publishable library

What it deliberately is not

No formatter and no linter: oxlint and oxfmt are npm binaries that already work, and neither oxc_formatter nor oxc_linter is published as a usable library crate. No package installer — esrun resolves an existing node_modules and installs nothing, which is correct. No task runner, and no browser dev server with HMR: esdev build's own need is one deployable file, not an asset graph. What esdev does offer, rather than build one, is the bundler and the watcher as modules — so the framework that wants a dev server can be a program on this runtime instead of a Node script.

Last updated on
Edit this page