Read OSS

tsup Architecture Overview: Navigating the Codebase

Intermediate

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.json bin field 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 is tsup 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 consume NormalizedOptions. The Options type is exclusively for user-facing surfaces (config files, CLI parsing, the defineConfig function).

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.