Read OSS

tsup 架构概览:读懂代码库

中级

前置知识

  • 具备 TypeScript 和 Node.js 模块系统的基础知识
  • 对打包工具的作用有基本认识(入口文件、输出格式、外部依赖等)

tsup 架构概览:读懂代码库

tsup 将自己定位为"基于 esbuild 的零配置 TypeScript 打包工具",对大多数用户来说,了解这些就够了。但在这层简洁的表面之下,tsup 是一条精心设计的处理流水线,协调着 esbuild、Rollup、TypeScript 编译器、SWC、Sucrase 和 Terser 的协同工作——每种工具都被部署在最能发挥其优势的环节。本文将带你梳理整个代码库的结构,无论你是打算贡献代码、开发插件,还是单纯想弄清楚库文件是如何从 .ts 变成可发布产物的,都能从中找到方向。

仓库结构一览

tsup 的代码库相当精简。src/ 目录下约 20 个源文件承担了所有核心工作,按功能分别组织在 esbuild 插件、tsup 插件以及 Rollup 相关的 DTS 子目录中。

路径 作用
src/index.ts 程序化 API:build()defineConfig()normalizeOptions()
src/cli-default.ts tsup 命令的入口文件
src/cli-node.ts tsup-node 命令的入口文件
src/cli-main.ts 基于 cac 的 CLI 参数解析
src/esbuild/ esbuild 编排逻辑及 esbuild 插件(external、postcss、svelte、swc 等)
src/plugins/ tsup 构建后插件(shebang、cjs-splitting、tree-shaking、terser 等)
src/rollup.ts 通过 rollup-plugin-dts 处理 --dts 的 Worker 线程
src/rollup/ 用于类型解析的自定义 Rollup 插件
src/tsc.ts 用于 --experimental-dts 的程序化 TypeScript 编译器
src/api-extractor.ts 用于 --experimental-dts 的 API Extractor 集成
src/exports.ts 实验性 DTS 的重导出语句格式化
assets/ 注入到产物中的 CJS 和 ESM shim 文件
test/ 基于 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

assets/ 目录只有两个文件——assets/cjs_shims.jsassets/esm_shims.js——用于填充跨格式的全局变量,例如在 CJS 中模拟 import.meta.url,在 ESM 中模拟 __dirname/__filename。我们将在第 3 篇文章中详细介绍。

三个入口:tsup、tsup-node 与 build()

tsup 提供两个 CLI 命令和一个程序化 API,而这两个 CLI 都极为简洁——每个文件不超过 10 行。

src/cli-default.tstsup 命令的入口:

#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'

main().catch(handleError)

src/cli-node.tstsup-node 的入口,仅多了一个预设选项:

#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'

main({
  skipNodeModulesBundle: true,
}).catch(handleError)

两者唯一的区别是 skipNodeModulesBundle: true,这会让 esbuild 将 node_modules 中的所有依赖标记为外部依赖,而不是打包进去。对于 Node.js 应用(相对于库而言),这是更合适的默认行为。

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"]

真正的 CLI 逻辑位于 src/cli-main.ts,它使用 cac 定义了约 30 个命令行参数。注意第 104 行有一处关键的性能优化:build() 是在 .action() 回调内部通过动态 import('.') 懒加载的:

.action(async (files: string[], flags) => {
    const { build } = await import('.')
    // ...
    await build(options)
})

这意味着当你运行 tsup --helptsup --version 时,整个构建流水线——esbuild、Rollup、TypeScript——都不会被加载。由于 cac 处理帮助和版本信息无需触发 action,CLI 的启动速度因此得以保持轻快。

提示: package.jsonbin 字段直接指向编译产物:"tsup": "dist/cli-default.js""tsup-node": "dist/cli-node.js"。tsup 自己也在使用自己打包——其构建命令正是 tsup src/cli-*.ts src/index.ts src/rollup.ts --clean --splitting

build() 编排器与并行任务设计

src/index.ts 中的 build() 函数是 tsup 的核心。它负责加载配置、规范化选项,然后并行调度两条独立的任务流:

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"]

第 455 行的并行设计看似简单,却至关重要:

await Promise.all([dtsTask(), mainTasks()])

JavaScript 打包与声明文件生成是完全解耦的。JS 流水线使用 esbuild 来保证速度;DTS 流水线则使用 Rollup(配合 rollup-plugin-dts)或 TypeScript 编译器(配合 API Extractor)。两条流水线除了共享规范化后的选项之外,没有任何其他交集——而且由于函数无法跨线程传递,选项在发送给 Worker 线程之前还必须经过序列化处理。

mainTasks() 内部,buildAll() 函数通过第 312–347 行的另一个 Promise.all() 为每种输出格式并行执行 runEsbuild()。如果你同时构建 cjsesm,两个 esbuild 实例会并发运行,各自持有独立的 PluginContainer 实例。

双层插件架构:概览

tsup 拥有两套独立的插件层,分别在构建的不同阶段发挥作用。在深入第 3 和第 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"]

第一层插件在 esbuild 构建过程中运行,通过 esbuild 的 onResolveonLoad 钩子,在文件进入打包流程之前对其进行转换——解析外部依赖、处理 CSS、编译 Svelte、生成装饰器元数据。

第二层插件在 esbuild 完成之后运行,操作对象是保存在内存中的完整输出 chunk。它们实现了 tsup 的 Plugin 接口,提供 renderChunkbuildEnd 等钩子。CJS 分包、tree-shaking、代码压缩和文件权限设置都在这一层完成。

这一机制的关键在于 esbuild 配置中的 write: false——esbuild 将所有输出以内存中 Uint8Array 缓冲区的形式返回,而不是直接写入磁盘,从而让 tsup 插件层有机会在最终落盘之前对所有内容进行处理。

选项与类型:Options 与 NormalizedOptions

tsup 在 src/options.ts 中通过两个类型将面向用户的配置与内部状态清晰地区分开来。

Options 类型(第 103–262 行)代表用户可以提供的配置项,包含约 40 个可选字段。所有字段均为可选,默认值会在后续步骤中填充:

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
}

NormalizedOptions 类型(第 269–279 行)才是所有下游代码实际使用的类型:

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()

来自 ts-essentialsMarkRequired 工具类型确保 entryoutDir 必定存在。dts 字段从三种可能的输入形式(boolean | string | DtsConfig)统一收敛为 DtsConfig | undefined。而 format 也始终是 Format[] 数组,不再是单个字符串。

这种设计意味着,normalizeOptions() 之后的所有函数都可以信任输入数据的结构,无需再做防御性检查。看似一个细小的设计决策,却从根源上消除了一整类 bug。

提示: 向 tsup 贡献代码时,如果你编写的逻辑在 build() 启动后运行,请始终使用 NormalizedOptionsOptions 类型专属于面向用户的层面(配置文件、CLI 解析、defineConfig 函数)。

下一步

有了这张代码库的全局地图,第 2 篇文章将深入配置加载流水线——tsup 如何在七种支持的配置格式中发现配置文件、bundle-require 如何无需任何配置即可支持 TypeScript 配置文件,以及 normalizeOptions() 如何将 glob 匹配、tsconfig 路径和输出扩展名解析为我们刚刚探讨过的完整类型化 NormalizedOptions 对象。