tsup Architecture Overview: Navigating the Codebase
Prerequisites
- ›Basic familiarity with TypeScript and Node.js module systems
- ›High-level understanding of what a bundler does (entry points, output formats, externals)
tsup Architecture Overview: Navigating the Codebase
tsup bills itself as a "zero-config TypeScript bundler powered by esbuild," and for most users that's all they need to know. But under the hood, tsup is a carefully orchestrated pipeline that coordinates esbuild, Rollup, the TypeScript compiler, SWC, Sucrase, and Terser — each tool deployed precisely where it excels. This article maps out the codebase so you can navigate it with confidence, whether you're contributing a fix, building a plugin, or just curious about how your library gets from .ts to publishable artifacts.
Repository Layout at a Glance
The tsup repository is compact. Roughly 20 source files in src/ do all the heavy lifting, organized into logical subdirectories for esbuild plugins, tsup plugins, and Rollup-related DTS code.
| Path | Role |
|---|---|
src/index.ts |
Programmatic API: build(), defineConfig(), normalizeOptions() |
src/cli-default.ts |
tsup binary entry point |
src/cli-node.ts |
tsup-node binary entry point |
src/cli-main.ts |
CLI argument parsing with cac |
src/esbuild/ |
esbuild orchestration and esbuild plugins (external, postcss, svelte, swc, etc.) |
src/plugins/ |
tsup post-build plugins (shebang, cjs-splitting, tree shaking, terser, etc.) |
src/rollup.ts |
Worker thread for --dts via rollup-plugin-dts |
src/rollup/ |
Custom Rollup plugin for type resolution |
src/tsc.ts |
Programmatic TypeScript compiler for --experimental-dts |
src/api-extractor.ts |
API Extractor integration for --experimental-dts |
src/exports.ts |
Reexport statement formatting for experimental DTS |
assets/ |
CJS and ESM shim files injected into bundles |
test/ |
Integration tests using Vitest |
graph TD
subgraph "Source Tree"
CLI["src/cli-*.ts<br/>CLI entry points"]
IDX["src/index.ts<br/>build() orchestrator"]
ESB["src/esbuild/<br/>esbuild plugins"]
PLG["src/plugins/<br/>tsup plugins"]
ROL["src/rollup.ts<br/>DTS worker"]
TSC["src/tsc.ts<br/>TypeScript compiler"]
API["src/api-extractor.ts<br/>API Extractor"]
end
CLI --> IDX
IDX --> ESB
IDX --> PLG
IDX --> ROL
IDX --> TSC
TSC --> API
The assets/ directory contains just two files — assets/cjs_shims.js and assets/esm_shims.js — that polyfill cross-format globals like import.meta.url in CJS and __dirname/__filename in ESM. We'll examine these in detail in Article 3.
Three Entry Points: tsup, tsup-node, and build()
tsup exposes two CLI commands and one programmatic API. The CLIs are remarkably thin — each is under 10 lines.
The src/cli-default.ts file is the tsup binary:
#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'
main().catch(handleError)
And src/cli-node.ts is tsup-node, which adds a single preset option:
#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'
main({
skipNodeModulesBundle: true,
}).catch(handleError)
The only difference is skipNodeModulesBundle: true, which tells esbuild to externalize everything from node_modules instead of bundling it. This is the right default for Node.js applications (as opposed to libraries).
flowchart LR
A["tsup CLI"] -->|"main()"| C["cli-main.ts"]
B["tsup-node CLI"] -->|"main({skipNodeModulesBundle: true})"| C
C -->|"dynamic import('.')"| D["build()"]
D --> E["esbuild pipeline"]
D --> F["DTS pipeline"]
The real CLI logic lives in src/cli-main.ts, which uses cac to define approximately 30 flags. Notice a key performance optimization on line 104: build() is loaded via a dynamic import('.') inside the .action() callback:
.action(async (files: string[], flags) => {
const { build } = await import('.')
// ...
await build(options)
})
This means that when you run tsup --help or tsup --version, the entire build pipeline — esbuild, Rollup, TypeScript — is never loaded. CLI startup stays fast because cac can handle help and version without triggering the action.
Tip: The
package.jsonbinfield maps directly to the compiled output:"tsup": "dist/cli-default.js"and"tsup-node": "dist/cli-node.js". tsup dogfoods itself — its own build command istsup src/cli-*.ts src/index.ts src/rollup.ts --clean --splitting.
The build() Orchestrator and Parallel Task Design
The build() function in src/index.ts is the heart of tsup. It handles config loading, option normalization, and then dispatches two independent task trees in parallel:
flowchart TD
B["build(_options)"] --> CL["Load config file"]
CL --> NM["normalizeOptions()"]
NM --> PA["Promise.all()"]
PA --> DTS["dtsTask()"]
PA --> MAIN["mainTasks()"]
DTS --> EDTS{"experimentalDts?"}
EDTS -->|yes| TSC["tsc + API Extractor"]
EDTS -->|no| RDTS{"dts?"}
RDTS -->|yes| WORKER["Worker thread<br/>Rollup + rollup-plugin-dts"]
MAIN --> BA["buildAll()"]
BA --> FMT1["runEsbuild(format: 'cjs')"]
BA --> FMT2["runEsbuild(format: 'esm')"]
BA --> OS["onSuccess hook"]
The parallel design on line 455 is simple but important:
await Promise.all([dtsTask(), mainTasks()])
JavaScript bundling and declaration file generation are completely decoupled. The JS pipeline uses esbuild for speed; the DTS pipeline uses either Rollup (with rollup-plugin-dts) or the TypeScript compiler (with API Extractor). They share nothing except the normalized options — and even there, options must be serialized before being sent to a Worker thread because functions can't cross thread boundaries.
Inside mainTasks(), the buildAll() function runs runEsbuild() for each output format in parallel via another Promise.all() on lines 312-347. If you're building cjs and esm simultaneously, two esbuild invocations run concurrently, each with its own PluginContainer instance.
Two-Layer Plugin Architecture: Preview
tsup has two distinct plugin layers that operate at different stages of the build. Understanding this separation is essential before diving into the details in Articles 3 and 4.
flowchart TD
subgraph "Layer 1: esbuild Plugins"
direction TB
EP1["external.ts<br/>Resolve externals"]
EP2["node-protocol.ts<br/>Strip node: prefix"]
EP3["postcss.ts<br/>CSS processing"]
EP4["swc.ts<br/>Decorator metadata"]
EP5["svelte.ts<br/>Compile .svelte"]
EP6["native-node-module.ts<br/>.node binary handling"]
end
subgraph "Layer 2: tsup Plugins"
direction TB
TP1["shebang<br/>CLI permissions"]
TP2["tree-shaking<br/>Rollup pass"]
TP3["cjs-splitting<br/>ESM→CJS via Sucrase"]
TP4["cjs-interop<br/>Default export compat"]
TP5["swc-target<br/>ES5/ES3 downlevel"]
TP6["terser<br/>Minification"]
TP7["size-reporter<br/>Output stats"]
end
SRC["Source files"] --> EP1
EP6 --> OUT["esbuild outputFiles<br/>(in memory)"]
OUT --> TP1
TP7 --> DISK["Written to disk"]
Layer 1 plugins run during the esbuild build. They use esbuild's onResolve and onLoad hooks to transform individual files before they enter the bundle — resolving externals, processing CSS, compiling Svelte, emitting decorator metadata.
Layer 2 plugins run after esbuild finishes, operating on the complete output chunks held in memory. They implement the tsup Plugin interface with hooks like renderChunk and buildEnd. This is where CJS splitting, tree-shaking, minification, and file permissions happen.
The key enabling pattern is write: false in the esbuild configuration — esbuild returns all output as in-memory Uint8Array buffers instead of writing to disk, giving the tsup plugin layer a chance to transform everything before final output.
Options and Types: Options vs NormalizedOptions
tsup draws a clean line between user-facing configuration and internal state through two types defined in src/options.ts.
The Options type (lines 103–262) represents what users can provide — roughly 40 optional fields. Everything is optional because defaults are applied later:
export type Options = {
name?: string
entry?: Entry
target?: Target | Target[]
format?: Format[] | Format // Note: can be a single string
dts?: boolean | string | DtsConfig // Polymorphic
// ... ~35 more optional fields
}
The NormalizedOptions type (lines 269–279) is what all downstream code actually consumes:
export type NormalizedOptions = Omit<
MarkRequired<Options, 'entry' | 'outDir'>,
'dts' | 'experimentalDts' | 'format'
> & {
dts?: DtsConfig
experimentalDts?: NormalizedExperimentalDtsConfig
tsconfigResolvePaths: Record<string, string[]>
tsconfigDecoratorMetadata?: boolean
format: Format[]
}
classDiagram
class Options {
+entry?: Entry
+outDir?: string
+format?: Format[] | Format
+dts?: boolean | string | DtsConfig
+target?: Target | Target[]
... ~35 more optional fields
}
class NormalizedOptions {
+entry: Entry ⟵ required
+outDir: string ⟵ required
+format: Format[] ⟵ always array
+dts?: DtsConfig ⟵ normalized
+tsconfigResolvePaths: Record
+tsconfigDecoratorMetadata?: boolean
}
Options --> NormalizedOptions : normalizeOptions()
The MarkRequired utility from ts-essentials enforces that entry and outDir are guaranteed present. The dts field is collapsed from its three possible input forms (boolean | string | DtsConfig) into just DtsConfig | undefined. And format is always an Format[] — never a bare string.
This pattern means every function after normalizeOptions() can trust the shape of its input without defensive checks. It's a small design decision, but it eliminates an entire class of bugs.
Tip: When contributing to tsup, if you're writing code that runs after
build()kicks off, always consumeNormalizedOptions. TheOptionstype is exclusively for user-facing surfaces (config files, CLI parsing, thedefineConfigfunction).
What's Next
Now that you have a map of the terrain, Article 2 will zoom into the configuration pipeline — how tsup discovers config files across seven supported formats, how bundle-require enables TypeScript config files without any setup, and how normalizeOptions() resolves globs, tsconfig paths, and output extensions into the fully typed NormalizedOptions object we just explored.