Project builds
Project config shape
Configuration is grouped by concern: build.targets describes outputs, resolve.alias describes shared import resolution, and dev describes the development loop. plugins, jsx and test stay at the top level because they apply across build targets or to their own command.
{ "build": { "targets": { "server": { "entry": "src/server.ts", "out": "dist/server.js" } } }, "resolve": { "alias": { "@": "./src" } }, "plugins": ["./plugins/mdx.mjs"], "dev": { "run": "server", "app": { "port": 8080 }, "watch": { "targets": ["server"], "paths": ["./config"] }, "outDir": ".dev" } }
dev.app.port is the app server's fallback port. In a static project, dev.server.port pins esdev's listener instead. The app owns the browser endpoint in full-stack mode; request proxying is not available yet. The old top-level targets, alias, and start keys are rejected. Move them to build.targets, resolve.alias, and dev. A top-level permissions block is rejected: development permissions come from esdev, and production grants belong on the esrun command.
Each target describes one bundle. An app that renders on the server and hydrates in the browser needs separate server and browser targets. A static site may also need a prerender target that runs after the bundles are built.
{ "build": { "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" } } } }
esdev build # every target esdev build --target=browser # one esdev build src/app.ts # one-off entry; project source settings still apply
Target options
| Key | |
|---|---|
entry | The module the bundle is rooted at — or an .html file (browser builds) |
out | One file |
outdir | A directory — what a browser target needs, since chunks land beside the entry |
platform | server (default) or browser |
assets | Copied into the output: a file by name, a directory by its contents |
then | "run" — execute the output once built |
lib | true — publish a library instead of deploying a bundle (libraries) |
format | A library's module systems: "esm", "cjs", or both as a list |
types | false — skip declarations |
dts-bundle | true, or the entry to link from |
css-urls | A library stylesheet's url() files: "copy" (default) or "inline" |
minify, define, conditions, sourcemap | As the flags, for this target alone |
resolve.alias is a project-level setting shared by targets and source commands; it is not a target option. See Aliases.
define values keep the JSON type you wrote: "MODE": "dev" replaces with a string, "PORT": 8080 with a number.
Flags on a project build
A flag applies to every target built, and beats the file:
| Flag | On each target |
|---|---|
--minify | Turns minify on |
--sourcemap[=<kind>] | Replaces sourcemap |
--define, --conditions | Added after the target's own, so a --define wins a name both set |
--alias | Replaces the alias of the same name, or adds one |
--out, --lib, --format, --no-types, --dts-bundle and --css-urls are refused: a project build writes where each target says, and a target says whether it is a library in the file.
JSX
esdev compiles JSX for whatever framework the project uses, and picks none of them for you. Nothing is assumed: a file with JSX in it and no answer anywhere is refused, by name, with the two keys that answer it. Say it once, in a jsx section, and every command that compiles — esdev test, esdev build, esdev start — uses it.
Either the compiler imports the function, or the module already has one:
{ "jsx": { "importSource": "preact" } }
{ "jsx": { "factory": "h", "fragment": "Fragment" } }
| Key | Meaning |
|---|---|
importSource | The package to import jsx/jsxs/Fragment from — "preact", "solid-js", "react". The compiler writes the import. |
factory | A function already in scope to call for each element — "h", "React.createElement". The module imports it itself. |
fragment | What <>…</> calls, alongside factory |
development | true — add __source and __self to every element |
reactCompiler | true — opt into OXC's experimental React Compiler (requires importSource: "react", targets React 19) |
The two are alternatives, so naming both is refused. So is fragment without factory, and so is an empty jsx section.
React projects can opt into OXC's experimental React Compiler:
{ "jsx": { "importSource": "react", "reactCompiler": true } }
It runs before JSX lowering in both development and release builds. Browser targets use client output, while server targets use OXC's SSR output mode. The React templates enable it by default; other projects leave it off unless they opt in. The compiler is included in esdev, so a project does not need a separate compiler package. Use React 19 in the project. Because the compiler is experimental, its behaviour may change with future esdev releases.
A file overrides the project with the pragma it probably already carries, which is what makes a file borrowed from another project work unchanged — and what lets one MDX or island file compile differently from the rest:
/** @jsxImportSource preact */ /** @jsx h */ /** @jsxFrag Fragment */
A config may carry only a jsx section — or only test — with no targets at all: a project that is tested and never bundled still has something to say.
Aliases
Under resolve, because a rewrite is a property of the source tree rather than of one output:
{ "resolve": { "alias": { "@": "./src", "react": "preact/compat" } } }
A path is resolved against the project; anything else names a package. The longest match wins, so @/ui beats @. --alias=@=./src replaces the alias of the same name. esdev build <entry> reads the project's resolve.alias, jsx and plugins too, though not its targets: the entry is in the project's source tree.
esdev test and esdev <file> apply the same aliases to what they run unbundled, and so does tsconfig.json's paths, as the bundler reads it. See Imports. esrun never applies them: it runs the bundle, where they are already resolved. A library, whether --lib or a target with "lib": true, is built without them: a published module keeps the specifier its source wrote, and --lib refuses --alias.
import.meta.env
A browser bundle cannot read the environment at run time, so what configures it is compiled in — and what is compiled in is public, which is what the prefix says out loud:
PUBLIC_API_URL=https://api.example.com
fetch(`${import.meta.env.PUBLIC_API_URL}/users`); if (import.meta.env.DEV) { /* development-only code */ }
PUBLIC_ only | Everything else stays out of the artifact. A secret is read at run time through runtime:process |
.env, then the environment | A variable exported in the shell beats the file — CI needs that way round |
MODE, DEV, PROD | "development"/"production", and two booleans, from the build itself |
| The object too | const { PUBLIC_API_URL } = import.meta.env works; it is replaced whole |
Not in --lib | Which environment the code runs in is the consuming build's to say |
Plugins in a project
Plugins are declared once, at the top level, and every command loads them:
{ "plugins": [ "./plugins/mdx.js", { "module": "@otfw/compiler", "options": { "mode": "ssr" } } ], "build": { "targets": { "server": { "entry": "src/server.tsx", "out": "dist/server.js" }, "web": { "entry": "index.html", "outdir": "dist" } } } }
A target has no plugins of its own. A plugin that compiles differently per build reads where it is running from its hook context: ctx.platform, ctx.target, ctx.command and ctx.hot (context). A framework's rendering mode is the plugin's own option. See Writing a plugin for the hook contract.
Where plugins run
| Command | Which hooks |
|---|---|
esdev build | All eight (html for an HTML target) |
esdev start, esdev build <entry> | All but finish (html for an HTML target) |
esdev test --browser | All but html: the test is bundled into esdev's own page |
esdev test, esdev test --dom, esdev <file> | resolve, load, transform |
A module run unbundled goes through the same resolve, load and transform hooks a build calls, in the same order, and esdev's own passes run with them: a test importing a CSS Module gets its scoped names, and an image gets its URL. A virtual module a plugin serves is loaded under the esdev-virtual: scheme. There is no bundle, so start, end, bundle and finish do not run and ctx.emit() fails. ctx.resolve() finds a relative or absolute path, with the extensions and index files a bundler tries, and answers null for a package.
| Key | |
|---|---|
module | What to import |
export | Which export the plugin is; default: the default export |
options | What to call it with, when the export is a factory |
Plugins load into an isolate of their own and are kept for the run. They run under esdev's grant, in the project directory, with the same runtime: namespace any other program gets. A plugin you configured is code you chose to run.
The three shapes
| Stack | Targets |
|---|---|
| Backend | One out file |
| Frontend (SSG/SPA) | A browser outdir, plus a prerender target with then: "run" |
| Fullstack | Both |
A plugin's finish hook does the same work from inside the build, with each target's output directory and files in hand. then: "run" is how a static site is generated without a plugin: the bundle runs, and what it writes is the output. It runs in a child process, after every target is built. esdev start skips these steps unless dev.watch.targets names them; see esdev start.
Safe output replacement
Nothing lands where it is deployed until the whole build has worked. Every output path is mirrored under one staging directory beside the project (.esdev-build-<pid>-<n>). Only once every target and every then: "run" step has succeeded does anything move into place, so a failed build leaves the last working deployment whole.
A whole-project esdev build replaces each outdir, so stale content-hashed files cannot accumulate. The following are overlaid instead: a selected --target, esdev start, and an out file's containing directory. An outdir that holds the project itself is refused.
Configuration location
./esdev.json, or --config=<path> — paths inside it resolve against the file, not the working directory. esrun never reads it; deployment grants belong on the command that deploys the artifact.
End to end
esdev build server.tsx --out=dist/server.js --minify esrun --allow-listen=8080 dist/server.js
For HTML entries, assets and CSS Modules, see Browser builds.