tsup's Plugin System: Post-Build Transformations and Built-in Plugins
Prerequisites
- ›Completion of Article 3 (esbuild pipeline)
- ›Understanding of CJS/ESM interoperability patterns
- ›Familiarity with Rollup's bundle API for the tree-shaking section
tsup's Plugin System: Post-Build Transformations and Built-in Plugins
As we saw in Article 3, esbuild produces output files held in memory via the write: false pattern. The tsup plugin system operates on these in-memory chunks, applying transformations that esbuild can't handle natively — CJS code splitting, secondary tree shaking, ES5 downleveling, Terser minification, and more. This article dissects the plugin API and walks through every built-in plugin, explaining the specific problem each solves.
The Plugin Type and Lifecycle Hooks
The Plugin type defines four lifecycle hooks:
export type Plugin = {
name: string
esbuildOptions?: ModifyEsbuildOptions
buildStart?: BuildStart
renderChunk?: RenderChunk
buildEnd?: BuildEnd
}
| Hook | When | Sync/Async | Use case |
|---|---|---|---|
esbuildOptions |
Before esbuild runs | Sync | Modify esbuild config (e.g., override target) |
buildStart |
After esbuild config, before build | Async | Setup work, clean directories |
renderChunk |
After esbuild, per output chunk | Async | Transform code, adjust source maps |
buildEnd |
After all files written to disk | Async | Report sizes, set file permissions |
Each hook receives this bound to a PluginContext containing the current format, splitting flag, full options, and logger. This context is set by the PluginContainer before any hooks fire.
The renderChunk hook is the most powerful. It receives the chunk's code string and a ChunkInfo object containing the file path, source map, entry point info, exports list, and imports metadata. It can return { code, map } to transform the chunk, or undefined/null to skip.
Plugin Assembly and Ordering in buildAll()
The plugin chain is assembled in buildAll() inside src/index.ts:
const pluginContainer = new PluginContainer([
shebang(),
...(options.plugins || []),
treeShakingPlugin({ treeshake: options.treeshake, ... }),
cjsSplitting(),
cjsInterop(),
swcTarget(),
sizeReporter(),
terserPlugin({ minifyOptions: options.minify, ... }),
])
flowchart TD
A["1. shebang<br/>Detect #! lines, set mode 0o755"] --> B["2. User plugins<br/>Custom transforms"]
B --> C["3. treeShaking<br/>Rollup secondary pass"]
C --> D["4. cjsSplitting<br/>Sucrase ESM→CJS conversion"]
D --> E["5. cjsInterop<br/>module.exports = exports.default"]
E --> F["6. swcTarget<br/>ES5/ES3 downleveling"]
F --> G["7. sizeReporter<br/>Log output sizes"]
G --> H["8. terser<br/>Minification (last!)"]
The ordering is deliberate:
- Shebang first: detects
#!before any transform might alter the first line - User plugins early: gives user code maximum control
- Tree-shaking before CJS: Rollup works on ESM, which is what esbuild produces when CJS splitting is enabled
- CJS splitting after tree-shaking: converts ESM → CJS only after dead code is removed
- CJS interop after splitting: patches
module.exportson the CJS output - SWC target after format conversion: downlevels the final code shape
- Size reporter near-last: captures sizes after transforms but before minification
- Terser absolutely last: minification must happen on the final code
shebang Plugin: Preserving CLI File Permissions
The shebang plugin is the simplest in the chain, but it solves a real problem:
export const shebang = (): Plugin => {
return {
name: 'shebang',
renderChunk(_, info) {
if (
info.type === 'chunk' &&
/\.(cjs|js|mjs)$/.test(info.path) &&
info.code.startsWith('#!')
) {
info.mode = 0o755
}
},
}
}
When esbuild processes a file that starts with #!/usr/bin/env node, it preserves the shebang in the output. The plugin detects this and sets info.mode = 0o755 — the Unix executable permission bits. This mode is later applied by outputFile() when writing to disk, ensuring your CLI scripts are immediately executable after build without a manual chmod.
Note that this plugin mutates the info object directly rather than returning a new code/map pair. The mode property on ChunkInfo is specifically designed for this — it's picked up during the file write step in buildFinished().
cjsSplitting Plugin: Working Around esbuild's CJS Splitting Limitation
This is one of tsup's cleverest workarounds. esbuild does not support code splitting for CommonJS output. The solution: build as ESM (with splitting enabled), then convert the output to CJS afterward.
As we saw in Article 3, runEsbuild() overrides the format to 'esm' when CJS splitting is requested. The cjsSplitting plugin then transforms each ESM chunk to CJS using Sucrase:
async renderChunk(code, info) {
if (
!this.splitting ||
this.options.treeshake || // handled by rollup instead
this.format !== 'cjs' ||
info.type !== 'chunk' ||
!/\.(js|cjs)$/.test(info.path)
) {
return
}
const { transform } = await import('sucrase')
const result = transform(code, {
filePath: info.path,
transforms: ['imports'],
sourceMapOptions: this.options.sourcemap
? { compiledFilename: info.path }
: undefined,
})
return { code: result.code, map: result.sourceMap }
}
flowchart LR
A["Source files"] -->|"format: 'cjs', splitting: true"| B["esbuild builds as ESM<br/>(with splitting)"]
B --> C["ESM chunks with<br/>import/export syntax"]
C -->|"cjsSplitting plugin"| D["CJS chunks with<br/>require()/exports"]
D --> E["disk"]
Sucrase is chosen over a full parser because it's extremely fast — it only transforms import/export statements to require()/module.exports without touching anything else. The transforms: ['imports'] option tells Sucrase to do exactly this and nothing more.
The guard this.options.treeshake is important: when tree-shaking is enabled, Rollup handles the ESM→CJS conversion in the tree-shaking plugin instead, so cjsSplitting skips its work to avoid double-converting.
cjsInterop Plugin: Default Export Compatibility
When a CJS consumer does const lib = require('my-lib'), they typically expect to get the default export directly. But esbuild's CJS output puts the default export at exports.default. The cjsInterop plugin bridges this gap:
renderChunk(code, info) {
if (
!this.options.cjsInterop ||
this.format !== 'cjs' ||
info.type !== 'chunk' ||
!/\.(js|cjs)$/.test(info.path) ||
!info.entryPoint ||
info.exports?.length !== 1 ||
info.exports[0] !== 'default'
) {
return
}
return {
code: `${code}\nmodule.exports = exports.default;\n`,
map: info.map,
}
}
The plugin appends module.exports = exports.default; — but only when the chunk is an entry point that exports only a default export. The info.exports?.length !== 1 || info.exports[0] !== 'default' guard ensures that modules with named exports aren't broken by overwriting module.exports.
Tip: Enable
--cjsInteropwhen you're building a library with a single default export that CJS consumers willrequire(). But be cautious — if your module also has named exports, this flag shouldn't be used sincemodule.exports = exports.defaultwould shadow them.
treeShaking Plugin: Rollup as a Secondary Pass
esbuild's tree-shaking handles most cases, but for complex re-export patterns it sometimes leaves dead code behind. The treeShakingPlugin uses Rollup's superior tree-shaking as a post-processing step:
sequenceDiagram
participant ESB as esbuild output
participant RP as Rollup (virtual)
participant OUT as Final output
ESB->>RP: Feed chunk code as virtual module
Note over RP: resolveId: only resolve self<br/>load: return esbuild's code
RP->>RP: Tree-shake with<br/>preserveEntrySignatures: 'exports-only'
RP->>RP: Generate output in target format
RP-->>OUT: Smaller code + map
The key insight is the virtual module setup on lines 26-37:
plugins: [
{
name: 'tsup',
resolveId(source) {
if (source === info.path) return source
return false // externalize everything else
},
load(id) {
if (id === info.path) return { code, map: info.map }
},
},
],
The virtual plugin treats the chunk's own path as the only known module — everything else is marked external with return false. This means Rollup only tree-shakes the code within the chunk without trying to resolve any imports. The treeshake option passed to Rollup can be a boolean, a preset string ('recommended', 'smallest', 'safest'), or a full Rollup TreeshakingOptions object.
swcTarget Plugin: Downleveling to ES5/ES3
esbuild's ES5 support is incomplete — it can't transform certain class-related syntax. The swcTarget plugin intercepts this:
esbuildOptions(options) {
if (
typeof options.target === 'string' &&
TARGETS.includes(options.target as any) // ['es5', 'es3']
) {
target = options.target as any
options.target = 'es2020' // Override esbuild to target modern JS
enabled = true
}
},
In the esbuildOptions hook, it detects es5 or es3 targets and replaces them with es2020. esbuild builds modern JavaScript, and then in renderChunk, SWC's transform handles the full downleveling:
const result = await swc.transform(code, {
filename: info.path,
sourceMaps: this.options.sourcemap,
jsc: {
target, // 'es5' or 'es3'
parser: { syntax: 'ecmascript' },
},
module: {
type: this.format === 'cjs' ? 'commonjs' : 'es6',
},
})
This is a pragmatic design: esbuild is fast and handles 95% of the work, SWC covers the remaining edge cases that esbuild can't handle for legacy targets.
terser Plugin: Minification as a Post-Process Step
The terserPlugin runs last in the chain, which is critical — minifying before other transforms would make their job much harder and produce worse results.
The plugin only activates when minify is set to the string 'terser' (as opposed to boolean true, which uses esbuild's built-in minifier):
if (minifyOptions !== 'terser' || !/\.(cjs|js|mjs)$/.test(info.path))
return
It applies format-specific Terser options automatically:
if (format === 'esm') {
defaultOptions.module = true
} else if (!(format === 'iife' && globalName !== undefined)) {
defaultOptions.toplevel = true
}
ESM output gets module: true (enables ESM-specific optimizations). CJS and IIFE-without-globalName get toplevel: true (allows mangling of top-level declarations). IIFE with a globalName skips toplevel to preserve the global variable name.
Like the SWC plugin, Terser is loaded via localRequire() — it's an optional dependency that must be installed explicitly. The error message guides users:
if (!terser) {
throw new PrettyError(
'terser is required for terser minification. Please install it with `npm install terser -D`',
)
}
sizeReporter Plugin: Output Size Display
The sizeReporter is a buildEnd plugin — it runs after all files are written to disk:
buildEnd({ writtenFiles }) {
reportSize(
this.logger,
this.format,
writtenFiles.reduce((res, file) => {
return { ...res, [file.name]: file.size }
}, {}),
)
}
It collects file names and sizes from the writtenFiles array and delegates to reportSize for formatted console output. The reporting uses prettyBytes for human-readable sizes and pads filenames for alignment. Simple, but it gives you immediate feedback on bundle size without needing external tools.
Tip: The plugin chain is a great model for building your own plugins. If you need custom post-processing — say, injecting license headers, patching imports, or running custom validation — implement the
Plugintype with arenderChunkhook and add it to thepluginsarray in your tsup config. Your plugin will run betweenshebangand the built-in transform plugins.
What's Next
We've now covered both layers of tsup's plugin architecture. Article 5 shifts to the other half of Promise.all([dtsTask(), mainTasks()]) — the declaration file generation pipeline. We'll compare the two strategies: the Rollup-based --dts path using a Worker thread and rollup-plugin-dts, versus the --experimental-dts path using the TypeScript compiler API with API Extractor.