Library builds
esdev build --lib src # src/** → dist/**.js + dist/**.d.ts
A library is an input to somebody else's build, so application defaults such as NODE_ENV, worker, and asset URLs are the consumer's to make.
| A directory, not an entry | Every module under it is built. Your exports map decides what a consumer may import. |
| The output is replaced | The build owns it; a failed build replaces nothing. |
| Nothing is tree-shaken | An export no current caller uses is still the API. |
| Dependencies stay external | Consumers can dedupe, override or patch them. |
| Module structure preserved | An exports subpath is a real file and a stack trace names a module. |
.d.ts beside each module | Derived from the annotations your source already carries. |
| Stylesheets keep their names | src/styles.css is dist/styles.css, so an exports map can name it exactly. |
Skipped: *.test.* and .d.ts files. An --out that holds your source or project is refused rather than replaced. A single-entry application build replaces nothing; whole-project application builds replace their outdirs.
Library stylesheets are CSS passthrough files: their source-relative paths and their relative @imports are kept intact. A library cannot hash a stylesheet whose exact path its consumer's exports map must name.
For example, publish src/styles.css directly from the path the consumer imports:
{ "exports": { ".": "./dist/index.js", "./styles.css": "./dist/styles.css" } }
esdev build --lib src # src/styles.css and src/theme.css → dist/styles.css and dist/theme.css
This is deliberately passthrough rather than a hashed deploy asset: a library's consumer controls caching and where its CSS is served.
A component that imports its stylesheet
A module may import its own stylesheet, so the two stay together while you work on them:
// src/button.js import "./button.css"; export function Button() { /* … */ }
The stylesheet is copied like any other, and the import is removed from the published module. A .css import works only in a bundler that understands it; in Node, in a server render or under esrun it fails. The consumer imports the stylesheet instead, through your exports:
import { Button } from "my-lib"; import "my-lib/button.css";
That subpath has to be in exports. One line covers every stylesheet:
{ "exports": { ".": "./dist/index.js", "./*.css": "./dist/*.css" } }
esdev does not edit package.json. When exports cannot reach a copied stylesheet, the build warns and names the line to add. A package with no exports field gets no warning, because every file in it is importable.
CSS Modules
A .module.css that a module imports ships scoped: the class names in dist/button.module.css are the ones the published JavaScript hands out, such as btn_c2e58308. A module it composes from is imported at the top of the file, so importing one stylesheet brings every rule its class names use.
Images and fonts a stylesheet uses
A file a library stylesheet names with url() ships with it. By default it is copied into dist/assets/ under a content-hashed name, and the url() points at the copy. To inline every such file into the stylesheet as a data: URL instead, set css-urls:
esdev build --lib src --css-urls=inline
{ "build": { "targets": { "kit": { "entry": "src", "lib": true, "outdir": "dist", "css-urls": "inline" } } } }
css-urls | url(./bg.png) becomes | Ships |
|---|---|---|
copy (default) | url("../assets/bg-1a2b3c4d.png") | dist/assets/bg-1a2b3c4d.png |
inline | url("data:image/png;base64,…") | nothing extra |
Inlining keeps a package to its stylesheets, at the cost of a larger CSS file: base64 is about a third bigger than the file, and an image used twice is embedded twice. Remote, rooted and data: URLs, @imports, and a path that names no file are left as written. Everything else in the stylesheet is unchanged.
Declarations
Declarations are derived from what the source says, never from what a checker infers. An exported signature has to state its type:
export const driver = defineDriver({ … }); // ✗ export const driver: Driver<Conn, Opts> = defineDriver({ … }); // ✓
A signature that does not meet this rule fails the build with the list of locations. This is TypeScript's isolatedDeclarations contract. --no-types skips declarations.
Imports in declarations are rewritten to the files the build writes: ./pool and ./pool.ts become ./pool.js, and a .d.cts names its .cjs sibling.
CommonJS output
esrun loads ES modules and nothing else. CommonJS is an output for consumers that still use require():
esdev build --lib src --format=esm,cjs esdev build --lib src --format=cjs
.cjs, whatever "type" says | Both trees in one directory are told apart by extension. |
.d.cts beside each .cjs | Under node16, a .cjs is typed by that declaration. |
| Named exports | require() returns the namespace, so a default export is .default. |
| All or nothing | No format lands unless every format succeeds. |
types goes inside each exports condition:
{ "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } } } }
Top-level await has no CommonJS form and stops the build. A runtime: import stays external in both trees.
One declaration file
For a package with one public entry, use a declaration bundle:
esdev build --lib src --dts-bundle # → dist/index.d.ts
Everything the entry exports transitively is reachable and linked; types needed only through a public type are inlined without widening the API. Collisions are renamed consistently, dependencies stay imports, and JSDoc is preserved.
Keep per-module declarations when your exports map has subpaths. Unsupported constructs such as module augmentations stop the build and name themselves.
Library options
| Flag | In esdev.json | |
|---|---|---|
--lib | "lib": true | Build a library |
--format=<list> | "format": ["esm", "cjs"] | esm (default), cjs, or both |
--no-types | "types": false | Skip .d.ts files |
--dts-bundle[=<entry>] | "dts-bundle": true or an entry | One .d.ts instead of one per module |
--css-urls=<copy|inline> | "css-urls": "inline" | What a stylesheet's url() files become; copy (default) |
--out=<dir> | "outdir": "dist" | Where to write it |
{ "build": { "targets": { "lib": { "entry": "src", "lib": true, "format": ["esm", "cjs"], "outdir": "dist", "types": true, "assets": ["README.md", "LICENSE"] } } } }
Library keys mean nothing on an application target and are refused rather than ignored, and a library is built without the project's aliases. Types are derived, not checked; esdev typecheck checks them.