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.

JSON
{
  "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.

JSON
{
  "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" }
    }
  }
}
Shell
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
entryThe module the bundle is rooted at — or an .html file (browser builds)
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
libtrue — publish a library instead of deploying a bundle (libraries)
formatA library's module systems: "esm", "cjs", or both as a list
typesfalse — skip declarations
dts-bundletrue, or the entry to link from
css-urlsA library stylesheet's url() files: "copy" (default) or "inline"
minify, define, conditions, sourcemapAs 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:

FlagOn each target
--minifyTurns minify on
--sourcemap[=<kind>]Replaces sourcemap
--define, --conditionsAdded after the target's own, so a --define wins a name both set
--aliasReplaces 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:

JSON
{ "jsx": { "importSource": "preact" } }
JSON
{ "jsx": { "factory": "h", "fragment": "Fragment" } }
KeyMeaning
importSourceThe package to import jsx/jsxs/Fragment from — "preact", "solid-js", "react". The compiler writes the import.
factoryA function already in scope to call for each element — "h", "React.createElement". The module imports it itself.
fragmentWhat <>…</> calls, alongside factory
developmenttrue — add __source and __self to every element
reactCompilertrue — 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:

JSON
{ "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:

JSX
/** @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:

JSON
{ "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:

Shell
PUBLIC_API_URL=https://api.example.com
TypeScript
fetch(`${import.meta.env.PUBLIC_API_URL}/users`);
if (import.meta.env.DEV) { /* development-only code */ }
PUBLIC_ onlyEverything else stays out of the artifact. A secret is read at run time through runtime:process
.env, then the environmentA 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 tooconst { PUBLIC_API_URL } = import.meta.env works; it is replaced whole
Not in --libWhich 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:

JSON
{
  "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

CommandWhich hooks
esdev buildAll eight (html for an HTML target)
esdev start, esdev build <entry>All but finish (html for an HTML target)
esdev test --browserAll 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
moduleWhat to import
exportWhich export the plugin is; default: the default export
optionsWhat 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

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

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

Shell
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.

Last updated on
Edit this page