Read OSS

The esbuild Build Pipeline: Orchestration, Plugins, and Output Processing

Advanced

Prerequisites

  • Completion of Articles 1–2
  • Working knowledge of the esbuild Plugin API (onResolve, onLoad, onEnd hooks)
  • Understanding of CJS vs ESM module semantics
  • Familiarity with source maps

The esbuild Build Pipeline: Orchestration, Plugins, and Output Processing

At its core, tsup is a bridge between your TypeScript source and esbuild, but that bridge is far from trivial. The runEsbuild() function assembles a complete esbuild configuration from NormalizedOptions, runs the build with output held in memory, then feeds those results through a plugin pipeline before anything touches disk. This article walks through every stage of that process.

Configuring esbuild: Auto-Externals and Format Selection

The first thing runEsbuild() does is figure out what should be external. On lines 77-83, production dependencies are read from package.json and converted to regular expression patterns:

const deps = await getProductionDeps(process.cwd())
const external = [
  ...deps.map((dep) => new RegExp(`^${dep}($|\/|\\)`)),
  ...(await generateExternal(options.external || [])),
]

The regex ^lodash($|\/|\\) matches both lodash and deep imports like lodash/get. getProductionDeps() combines dependencies and peerDependencies — the assumption is correct: libraries should not bundle their production dependencies.

A subtle but important detail is the format override on line 164-165:

format:
  (format === 'cjs' && splitting) || options.treeshake ? 'esm' : format,

When CJS output is requested with code splitting enabled, or when tree-shaking is active, esbuild actually builds as ESM. This is because esbuild doesn't natively support code splitting for CJS output. The ESM output is later converted to CJS by the cjsSplitting plugin (covered in Article 4). Similarly, the tree-shaking plugin uses Rollup on esbuild's ESM output, then generates the final CJS from Rollup.

Requested Format Splitting Treeshake Actual esbuild Format
cjs false false cjs
cjs true false esm (converted by cjsSplitting plugin)
cjs any true esm (converted by tree-shaking plugin)
esm any any esm
iife N/A any iife

The External Plugin: skipNodeModulesBundle and noExternal

The auto-external regex patterns from package.json need a custom esbuild plugin because esbuild's built-in external option doesn't support regular expressions. The externalPlugin has two distinct operating modes.

flowchart TD
    A["Import resolved"] --> B{"skipNodeModulesBundle?"}
    B -->|yes| C{"Matches tsconfig paths?"}
    C -->|yes| D["Bundle it — let esbuild resolve"]
    C -->|no| E{"Matches noExternal?"}
    E -->|yes| D
    E -->|no| F{"Matches explicit external?"}
    F -->|yes| G["Mark external"]
    F -->|no| H{"Is non-relative import?<br/>(looks like node_module)"}
    H -->|yes| G
    H -->|no| D
    B -->|no| I{"Matches noExternal?"}
    I -->|yes| D
    I -->|no| J{"Matches external?"}
    J -->|yes| G
    J -->|no| D

In normal mode (used by tsup), the plugin only checks explicit external and noExternal patterns. In skipNodeModulesBundle mode (used by tsup-node), any import that doesn't look like a relative or absolute path is externalized — unless it matches noExternal or a tsconfig path alias.

The regex on line 5 is the heuristic for detecting module imports:

const NON_NODE_MODULE_RE = /^[A-Z]:[/\\]|^\.{0,2}\/|^\.{1,2}$/

If an import path doesn't match this regex (i.e., it's not a relative path, absolute path, or drive letter), it's treated as a node_module and externalized.

Bundled esbuild Plugins Tour

tsup registers six esbuild plugins, assembled on lines 121-150. Each solves a specific gap in esbuild's capabilities.

Plugin File What it does
nodeProtocolPlugin node-protocol.ts Strips node: prefix from imports (e.g., node:pathpath) for older Node.js runtimes
externalPlugin external.ts Regex-based external resolution (covered above)
swcPlugin swc.ts Runs SWC on every .ts/.js file to emit decorator metadata when emitDecoratorMetadata is enabled in tsconfig
nativeNodeModulesPlugin native-node-module.ts Handles .node binary addons by resolving them and generating a require() wrapper
postcssPlugin postcss.ts Loads CSS files, runs PostCSS transforms if configured, optionally injects CSS as JS
sveltePlugin svelte.ts Compiles .svelte files using the Svelte compiler, with TypeScript preprocessing

The SWC plugin is particularly interesting. esbuild doesn't support TypeScript's emitDecoratorMetadata feature, which is required by frameworks like NestJS and TypeORM. When tsconfigDecoratorMetadata is true, the swcPlugin intercepts every TypeScript file via onLoad, runs SWC's transformFile with decoratorMetadata: true, and feeds the result back to esbuild:

const jsc: JscConfig = {
  parser: {
    syntax: isTs ? 'typescript' : 'ecmascript',
    decorators: true,
  },
  transform: {
    legacyDecorator: true,
    decoratorMetadata: true,
  },
  keepClassNames: true,
  target: 'es2022',
}

Note that it forces keepClassNames: true and target: 'es2022' — this ensures class names survive for reflection metadata, and the output is modern enough that esbuild can handle the rest.

Tip: The SWC, PostCSS, and Svelte plugins all use localRequire() from utils.ts, which resolves packages relative to the user's project directory. This means users only need to install these optional dependencies when they actually use the features — tsup won't crash at import time.

The write:false Pattern and In-Memory Output

The single most important esbuild option in tsup's configuration is on line 234:

write: false,

With write: false, esbuild returns all output files as OutputFile[] objects in memory — each containing a path, text, and contents (Uint8Array). Nothing is written to disk.

sequenceDiagram
    participant RE as runEsbuild()
    participant EB as esbuild
    participant PC as PluginContainer
    participant FS as File System

    RE->>EB: esbuild({ write: false, ... })
    EB-->>RE: { outputFiles: OutputFile[], metafile }
    RE->>PC: buildFinished({ outputFiles, metafile })
    PC->>PC: Filter out .map files
    PC->>PC: Classify as ChunkInfo or AssetInfo
    loop For each chunk
        PC->>PC: Run renderChunk() through all plugins
        PC->>PC: Merge source maps if plugin returned map
    end
    PC->>FS: outputFile() — write to disk
    PC->>PC: Call buildEnd() on all plugins

This pattern is the prerequisite for everything in the tsup plugin layer. Without it, there would be no opportunity to transform CJS output, run secondary tree-shaking, apply Terser minification, or fix up shebangs. The trade-off is memory usage — all output is held in memory simultaneously — but for library builds this is rarely a concern.

PluginContainer.buildFinished(): The Output Processing Pipeline

After esbuild completes, pluginContainer.buildFinished() takes over. This method orchestrates the entire post-build pipeline.

First, output files are classified on lines 131-149. Source map files are filtered out (they're associated with their parent file instead). JS and CSS files become ChunkInfo objects with their code as strings; everything else becomes AssetInfo with raw bytes.

Then, for each chunk, every plugin's renderChunk hook is called sequentially:

for (const plugin of this.plugins) {
  if (info.type === 'chunk' && plugin.renderChunk) {
    const result = await plugin.renderChunk.call(
      this.getContext(),
      info.code,
      info,
    )
    if (result) {
      info.code = result.code
      // source map merging...
    }
  }
}
flowchart LR
    IN["esbuild output chunk"] --> S["shebang"]
    S --> U["user plugins"]
    U --> TS["tree-shaking"]
    TS --> CS["cjs-splitting"]
    CS --> CI["cjs-interop"]
    CI --> SW["swc-target"]
    SW --> SR["size-reporter"]
    SR --> TR["terser"]
    TR --> OUT["Final code + map"]

The order matters. Shebang must run first to detect #! lines. Tree-shaking and CJS splitting must happen before terser minification. The size reporter runs late so it captures near-final sizes.

Source Map Merging Across Transformation Passes

When a plugin returns both code and map, the PluginContainer must merge the new source map with the existing one. This happens on lines 164-176:

if (result.map) {
  const originalConsumer = await new SourceMapConsumer(
    parseSourceMap(info.map),
  )
  const newConsumer = await new SourceMapConsumer(
    parseSourceMap(result.map),
  )
  const generator = SourceMapGenerator.fromSourceMap(newConsumer)
  generator.applySourceMap(originalConsumer, info.path)
  info.map = generator.toJSON()
  originalConsumer.destroy()
  newConsumer.destroy()
}
sequenceDiagram
    participant O as Original Map<br/>(esbuild → source)
    participant N as New Map<br/>(plugin → esbuild output)
    participant G as SourceMapGenerator

    Note over G: Start from new map
    G->>G: fromSourceMap(newConsumer)
    G->>G: applySourceMap(originalConsumer)
    Note over G: Composed map now traces<br/>plugin output → original source

The source-map library's applySourceMap method does the heavy lifting — it traces mappings from the plugin's output positions back through the esbuild-generated map to the original source positions. Without this composition, debugging would show you transformed code positions instead of your original TypeScript.

After all plugins run, the final code and source maps are written to disk via outputFile(). The buildEnd hook is then called on each plugin with metadata about the written files — this is where the size reporter does its work.

CJS and ESM Shim Injection

When --shims is enabled, tsup injects polyfill files via esbuild's inject option. The CJS shim assets/cjs_shims.js provides import.meta.url in CJS contexts:

const getImportMetaUrl = () =>
  typeof document === "undefined"
    ? new URL(`file:${__filename}`).href
    : (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT')
      ? document.currentScript.src
      : new URL("main.js", document.baseURI).href;

export const importMetaUrl = /* @__PURE__ */ getImportMetaUrl()

Notice the function-wrapper pattern. The comment on lines 1-4 explains why: there's a bug in esbuild where exporting import.meta.url as a const directly causes esbuild to always inject it, even in formats where it's not needed. Wrapping it in a function avoids this.

The ESM shim assets/esm_shims.js provides the reverse — __dirname and __filename in ESM contexts using import.meta.url:

import path from 'node:path'
import { fileURLToPath } from 'node:url'

const getFilename = () => fileURLToPath(import.meta.url)
const getDirname = () => path.dirname(getFilename())

export const __dirname = /* @__PURE__ */ getDirname()
export const __filename = /* @__PURE__ */ getFilename()

Both use /* @__PURE__ */ annotations so that tree-shakers can remove them if unused. The injection is wired up on lines 220-228 of runEsbuild(), conditional on both the format and the shims option.

Tip: If you encounter ReferenceError: __dirname is not defined in ESM output, or import.meta.url is not available in CJS output, enabling --shims is the fix. tsup injects the appropriate polyfill automatically.

What's Next

With esbuild's output now sitting in memory as ChunkInfo objects, Article 4 takes a detailed tour through every built-in tsup plugin — from the shebang handler that sets file permissions, through the clever CJS splitting workaround, to Terser minification at the end of the chain.