esdev build

Shell
esdev build server.ts                          # → dist/server.js
esdev build src/app.ts --out=dist/app.js --minify

One entry, one file, ES modules out. runtime:* imports are left for the runtime to serve, and CommonJS dependencies are converted on the way in.

The CommonJS answer

esrun runs ES modules only, and a large share of npm — React included — still ships CommonJS. There were two ways out: teach the runtime require, or convert on the developer's machine.

Teaching the runtime require would break the property that a deployed artifact is the text that was reviewed. So the bundler absorbs CJS→ESM at build time and esrun receives ordinary ESM. The non-goal holds completely rather than being narrowed:

Shell
esdev build server.js         # a CJS dependency goes in
esrun dist/server.js          # ordinary ESM comes out

It also shortens the deploy line

An unbundled program needs --allow-imports, because the loader walks node_modules at runtime. A bundle has no imports left to resolve:

Shell
esrun --allow-imports --allow-listen=8080 app.js  # unbundled
esrun --allow-listen=8080 dist/app.js             # bundled

The build is a security step, not only a packaging one.

Options

Flag
--out=<file>Where to write it (default dist/<entry>.js)
--minifyMinify the output
--conditions=<list>Extra exports conditions, comma-separated. These add to the defaults (import, default, worker)
--define=<name>=<value>Replace <name> at build time. process.env.NODE_ENV defaults to "production"

What it gets right for you

Four settings are the actual product, and each is silent when wrong — which is why this is a command rather than documentation telling you to run a bundler.

runtime:* stays externalIt is served by the runtime and has no file behind it. Inlining it produces a bundle that fails at its first import.
ESM outputThe runtime has no other module system.
process.env.NODE_ENV is definedPackages branch on it before doing anything, and there is no process global here — undefined is a crash, not a missed optimisation.
The worker condition is assertedIt is how a package hands over its Web-API build: react-dom/server resolves to a Web Streams implementation under it, and a node:stream one without it.

--conditions adds rather than replaces, so asking for one more cannot silently cost you worker. The runtime's condition set stays standards-only; the escape hatch belongs to the bundler, where you chose to run it, not to a server resolving imports under load.

A dynamic import() emits a chunk beside the entry, content-hashed. That is what outdir below is for.

Not yet

Source maps, watching a build, and any output format but ESM. A minified bundle's stack traces are currently unmapped.

A project: esdev.json

A command line describes one bundle. An app that renders on the server and hydrates in the browser is two, from two entries, with two shapes of output — and the site it prerenders is a third that has to run.

JSON
{
  "targets": {
    "server":    { "entry": "src/server.ts", "out": "dist/server.js",
                   "assets": ["index.html", "public"] },
    "browser":   { "entry": "src/entry.client.tsx", "outdir": "dist/client",
                   "platform": "browser" },
    "prerender": { "entry": "src/prerender.ts", "out": "dist/prerender.js",
                   "then": "run" }
  }
}
Shell
esdev build                      # every target
esdev build --target=browser     # one
esdev build src/app.ts           # ignores the file entirely
Key
entryThe module the bundle is rooted at — or an .html file (below)
outOne file
outdirA directory — what a browser target needs, since chunks land beside the entry
platformserver (default) or browser
assetsCopied into the output: a file by name, a directory by its contents
then"run" — execute the output once built
minify, define, conditionsAs the flags, for this target alone

define values keep the JSON type you wrote: "MODE": "dev" replaces with a string, "PORT": 8080 with a number. A flag beats the file, so --minify takes a release build of a project whose day to day is unminified.

The three shapes

StackTargets
BackendOne out file
Frontend (SSG/SPA)A browser outdir, plus a prerender target with then: "run"
FullstackBoth

then: "run" is how a static site is generated without esdev knowing what one is: the bundle runs, and what it writes is the output. It runs in a child process, after every target is built.

Assets and the deployment

A relative path resolves against the entry module's directory, so a server bundle in dist/ reading index.html reads dist/index.html. Assets are copied there by the build, which is what makes dist/ the whole deployment — a directory copied by its contents, so public/styles.css is served at /styles.css with nothing rewriting an href.

An HTML entry

A server bundle starts at a module, because the runtime does. The browser starts at a document.

JSON
{ "targets": { "web": { "entry": "index.html", "outdir": "dist" } } }

The tags in it are the build's inputs:

HTML
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./src/entry.client.tsx"></script>

become, in dist/index.html:

HTML
<link rel="stylesheet" href="/assets/styles-621d3b66.css" />
<script type="module" src="/assets/entry.client-fccaa347.js" />
<script type="module">An entry — it and everything it imports become one browser bundle
Everything else relativeCopied: stylesheets, favicons, images, classic scripts
BothContent-hashed into <outdir>/assets, so the whole directory caches immutably
Everything else in the fileUntouched, byte for byte — title, meta, Open Graph, inline scripts

A relative path is an input. A rooted path (/assets/vendor.js), a URL and a data: URI are left exactly as written — the escape hatch for anything the build should keep out of.

Dynamic import() chunks land in the same directory, hashed by the bundler, and the entry still points at them.

A <link rel="stylesheet"> is an entry too. It and everything it @imports become one hashed file, and a relative url() is followed, so fonts and images travel with the stylesheet instead of arriving as 404s once it moves to /assets. --minify drops comments and collapses whitespace.

CSS Modules

A *.module.css imported from JavaScript is scoped to the file that declares it, and the import resolves to the mapping:

JavaScript
import styles from "./Button.module.css";  // { button: "button_a1b2c3d4" }

Class names, id selectors and @keyframes are renamed; the scoped name comes from the file's path, so a server build and a browser build agree without talking to each other. :global(…) opts out. Every module's CSS is collected into one hashed stylesheet and linked from the document — never injected from script, so nothing needs style-src 'unsafe-inline'.

composes reuses a class without repeating its rules — from the same file, from another module (composes: a from "./x.module.css"), or from outside the system (composes: a from global). The mapping's value becomes a list of class names, and it is transitive.

A .css that is not .module.css is emitted unscoped. That is what third-party stylesheets need: a library's own JavaScript emits its class names as hardcoded strings, so scoping them would break it.

What the CSS pipeline does not do

No syntax lowering and no vendor prefixing — nesting and color-mix() are supported across the browsers this targets. No value-level minification, and no per-file typed class names.

Where the file is

./esdev.json, or --config=<path> — paths inside it resolve against the file, not the working directory.

esrun never reads it

A production binary that picked up a checked-in file granting itself capabilities is what the capability model exists to prevent. The grant a service runs under belongs on the command that deployed it.

End to end

React 19 streaming SSR behind Hono, from CommonJS packages, served by esrun with no filesystem or import access at all:

Shell
esdev build server.tsx --out=dist/server.js --minify
esrun --allow-listen=8080 dist/server.js

Libraries: --lib

Shell
esdev build --lib src            # src/** → dist/**.js + dist/**.d.ts

A library is an input to somebody else's build, so every default above is theirs to make and --lib makes none of them.

A directory, not an entryEvery module under it is built, the way tsc builds a rootDir. Your exports map decides what a consumer may import, not what an entry reaches.
The output is emptied firstThe build owns it. A stale file in dist is a file "files": ["dist"] publishes.
Nothing is tree-shakenAn export no current caller uses is not dead code — it is the API.
Dependencies stay externalOnly relative and absolute imports are emitted, so a consumer can still dedupe, override or patch.
Module structure preservedA subpath in exports is a real file, and a stack trace names a module.
Nothing defined, no condition assertedNODE_ENV and worker belong to the build that consumes this.
.d.ts beside each moduleDerived from the annotations your source already carries.

Skipped: *.test.* and .d.ts files. An --out that holds your source or your project is refused rather than emptied; an application build never cleans, since its --out is one file in a directory that may hold other things.

Declarations

Derived from what the source says, never from what a checker infers — the same contract type-stripping has. So an exported signature has to state its type:

TypeScript
export const driver = defineDriver({ … });                    // ✗
export const driver: Driver<Conn, Opts> = defineDriver({ … }); // ✓

A signature that does not fails the build with the list, rather than getting a guessed declaration nobody can see is wrong. TypeScript calls this rule isolatedDeclarations.

error: 2 declarations could not be derived:

  src/index.ts:150:14  TS9010: Variable must have an explicit type annotation.
  src/pool.ts:41:14    TS9010: Variable must have an explicit type annotation.

--no-types skips them.

One declaration file

A package whose exports map has a single entry wants one index.d.ts, not a mirror of a source layout nobody outside it should have to know:

Shell
esdev build --lib src --dts-bundle    # → dist/index.d.ts
Reachable, not everythingEverything the entry's exports name, transitively.
Inlined but not exportedA type reachable only through a public one is present — the public type needs it — without widening your surface.
Collisions renamedTwo modules with Options become Options and Options$1, and every site is rewritten.
Dependencies stay importsThe same line --lib draws for JavaScript.
JSDoc byte for byteIt is what an editor shows on hover.

Keep the per-module .d.ts if your exports map has subpaths — @you/pkg/pool has to find a real pool.d.ts.

A construct that cannot be linked into one file — a namespace import, export =, a module augmentation — stops the build and names itself. A .d.ts is believed: nothing runs it and no test covers it, so a wrong one is worse than none.

Written here, not borrowed

Neither tsc nor rolldown can do this — tsc has no declaration-bundling mode, and rolldown's Rust crates have no .d.ts support. The linker is ours, over oxc's parser and semantic analysis.

Options

Flag
--libBuild a library
--no-types--lib only: skip the .d.ts
--dts-bundle[=<entry>]--lib only: one .d.ts instead of one per module. Default entry <srcdir>/index.ts
--out=<dir>Where to write it (default dist)
Types are never checked

esdev erases and derives; it does not typecheck. That is your editor's job and tsc --noEmit's.

Last updated on
Edit this page