Read OSS

設定の読み込み:CLIフラグからNormalizedOptionsまで

中級

前提知識

  • 第1回の完了(アーキテクチャ概要)
  • TypeScriptのtsconfig.jsonオプションへの慣れ
  • globパターンの基本的な理解

設定の読み込み:CLIフラグからNormalizedOptionsまで

どのバンドラーにも設定パイプラインが必要です。tsupの設計思想はシンプルで、「ゼロ設定は本当にゼロであるべき、でもパワーユーザーはすべてをコントロールできるべき」という方針に基づいています。この記事では、tsupが設定ファイルを探し始める瞬間から、完全に解決された NormalizedOptions オブジェクトがesbuildパイプラインに渡される瞬間まで、一連の流れを追っていきます。

joyconによる設定ファイルの探索

tsupは7種類の設定ファイル形式と、package.json に埋め込むキーをサポートしています。探索ロジックは src/load.ts にあり、優先順位付きのファイル検索に joycon ライブラリを使っています。

const configPath = await configJoycon.resolve({
  files: configFile
    ? [configFile]
    : [
        'tsup.config.ts',
        'tsup.config.cts',
        'tsup.config.mts',
        'tsup.config.js',
        'tsup.config.cjs',
        'tsup.config.mjs',
        'tsup.config.json',
        'package.json',
      ],
  cwd,
  stopDir: path.parse(cwd).root,
  packageKey: 'tsup',
})
flowchart TD
    A["Start: loadTsupConfig(cwd)"] --> B{"Custom config<br/>file specified?"}
    B -->|yes| C["Search for that file only"]
    B -->|no| D["Search priority list:<br/>1. tsup.config.ts<br/>2. tsup.config.cts<br/>3. tsup.config.mts<br/>4. tsup.config.js<br/>5. tsup.config.cjs<br/>6. tsup.config.mjs<br/>7. tsup.config.json<br/>8. package.json#tsup"]
    C --> E{"Found?"}
    D --> E
    E -->|no| F["Return {} — no config"]
    E -->|yes, .json| G["Parse JSON,<br/>extract .tsup key if package.json"]
    E -->|yes, .ts/.js/.mts/.cjs/...| H["bundleRequire()"]
    H --> I["config.mod.tsup || config.mod.default || config.mod"]
    G --> J["Return { path, data }"]
    I --> J

検索はカレントディレクトリからファイルシステムのルート(stopDir)に向かって上位に進み、最初に見つかったファイルで停止します。TypeScriptのバリアント(.ts.cts.mts)が最初にチェックされるのは、tsupのメインターゲットがTypeScriptで書くユーザーだからこそ、適切なデフォルトといえます。

設定ファイルが見つからない場合、loadTsupConfig は空のオブジェクトを返します。tsupはCLIフラグだけで処理を進めます。これが「ゼロ設定」体験の仕組みで、tsup src/index.ts だけで動作するのはそのためです。

packageKey: 'tsup' パラメーターは、package.json に到達した際に tsup プロパティの中を参照するようjoyconに伝えます。この処理は 61行目 で明示的に行われています。

if (configPath.endsWith('package.json')) {
  data = data.tsup
}

bundle-requireによるTypeScript設定ファイルの実行

ここに課題があります。Node.jsは .ts ファイルをネイティブに require() できません。tsupはこれを bundle-require で解決しています。内部的にesbuildを使って設定ファイルをその場でコンパイルし、一時ファイルに書き出した後、その一時ファイルを require() する仕組みです。

核心となる呼び出しは 70〜76行目 にあります。

const config = await bundleRequire({
  filepath: configPath,
})
return {
  path: configPath,
  data: config.mod.tsup || config.mod.default || config.mod,
}
sequenceDiagram
    participant tsup as tsup (load.ts)
    participant br as bundle-require
    participant esbuild as esbuild
    participant node as Node.js

    tsup->>br: bundleRequire({ filepath: "tsup.config.ts" })
    br->>esbuild: Build tsup.config.ts → temp .js
    esbuild-->>br: Compiled JavaScript
    br->>node: require(tempFile)
    node-->>br: module.exports
    br-->>tsup: { mod: { default: ... } }
    tsup->>tsup: Extract mod.tsup || mod.default || mod

config.mod.tsup || config.mod.default || config.mod という解決チェーンは、3種類のエクスポートスタイルに対応しています。

  1. export const tsup = { ... }tsup という名前付きエクスポート
  2. export default { ... } — デフォルトエクスポート(最も一般的なパターン)
  3. module.exports = { ... } — CJSスタイル(モジュール全体が設定そのもの)

Tip: この bundle-require のアプローチは、Viteの vite.config.ts など他のツールでも使われています。esbuildが設定ファイルをミリ秒単位でコンパイルするため高速ですが、コンパイル後のコードを実際に実行する点に注意してください。設定ファイルはビルド時に動作するため、動的な設定が実現できます。

defineConfigの関数形式による動的設定

defineConfig は受け取った値をそのまま返す恒等関数です。その唯一の目的はTypeScriptのIntelliSenseです。

export const defineConfig = (
  options:
    | Options
    | Options[]
    | ((overrideOptions: Options) => MaybePromise<Options | Options[]>),
) => options

注目してほしいのは、この関数シグネチャが3つの形式を受け付けることです。単一の設定オブジェクト、設定の配列、そしてCLIオプションを受け取って両者のいずれかを返す関数です。この関数形式は強力で、tsupがどのように起動されたかに応じて設定を柔軟に変えることができます。

// tsup.config.ts
export default defineConfig((overrideOptions) => ({
  entry: ['src/index.ts'],
  format: overrideOptions.watch ? ['esm'] : ['cjs', 'esm'],
}))

関数形式の解決は build() の176〜179行目で行われます。

const configData =
  typeof config.data === 'function'
    ? await config.data(_options)
    : config.data

設定が配列を返す場合、各要素は 181行目Promise.all によって独立して処理されます。これが、ひとつの設定ファイルで複数のビルドターゲットを定義できる仕組みです。たとえば、エントリーポイントやフォーマットが異なるライブラリとCLIツールを同時にビルドする場合に活用できます。

flowchart TD
    A["config.data"] --> B{"typeof === 'function'?"}
    B -->|yes| C["config.data(_options)<br/>Pass CLI flags to function"]
    B -->|no| D["Use as-is"]
    C --> E{"Returns array?"}
    D --> E
    E -->|yes| F["Promise.all — process each config in parallel"]
    E -->|no| G["Process single config"]
    F --> H["normalizeOptions() × N"]
    G --> H

オプションの優先順位とnormalizeOptions()

normalizeOptions() は、緩い Options 型を厳格な NormalizedOptions 型に変換します。レイヤーの順序はシンプルで、CLIフラグが設定ファイルの値を上書きします。

const _options = {
  ...optionsFromConfigFile,
  ...optionsOverride,    // CLI flags win
}

その後、デフォルト値が適用されます。

オプション デフォルト値 備考
outDir 'dist' 標準的な出力ディレクトリ
format ['cjs'] 単一の文字列は配列にラップされる
target 'node16' tsconfigチェック後のフォールバック
removeNodeProtocol true importから node: プレフィックスを除去

dts オプションは 88〜95行目 で大きく正規化されます。boolean | string | DtsConfig として始まり、DtsConfig | undefined に変換されます。

dts:
  typeof _options.dts === 'boolean'
    ? _options.dts
      ? {}           // true → empty DtsConfig (use defaults)
      : undefined    // false → disabled
    : typeof _options.dts === 'string'
      ? { entry: _options.dts }  // string → entry shorthand
      : _options.dts,            // already DtsConfig

エントリーファイルもここで正規化されます。エントリーが文字列の配列(例:['src/**/*.ts'])の場合は、111行目tinyglobbyglob() で展開されます。Record<string, string> 形式の場合は、各ファイルの存在確認が行われます。

tsconfigとの統合:パス、デコレーター、ターゲット

基本的なデフォルト値の適用後、normalizeOptions()bundle-requireloadTsConfig ヘルパーを使ってプロジェクトの tsconfig.json を読み込みます。抽出されるデータは3つで、129〜155行目 で確認できます。

const tsconfig = loadTsConfig(process.cwd(), options.tsconfig)
if (tsconfig) {
  options.tsconfigResolvePaths = tsconfig.data?.compilerOptions?.paths || {}
  options.tsconfigDecoratorMetadata =
    tsconfig.data?.compilerOptions?.emitDecoratorMetadata
  // ...
  if (!options.target) {
    options.target = tsconfig.data?.compilerOptions?.target?.toLowerCase()
  }
}
flowchart TD
    TC["tsconfig.json"] --> PATHS["compilerOptions.paths<br/>→ tsconfigResolvePaths"]
    TC --> DEC["compilerOptions.emitDecoratorMetadata<br/>→ tsconfigDecoratorMetadata"]
    TC --> TGT["compilerOptions.target<br/>→ target (fallback)"]
    PATHS --> EXT["esbuild external plugin<br/>(resolve aliased paths)"]
    DEC --> SWC["SWC esbuild plugin<br/>(emit decorator metadata)"]
    TGT --> ESB["esbuild target option"]

tsconfigResolvePaths フィールドはexternalプラグイン(第3回)に渡され、@/utils のようなパスエイリアスが外部モジュールとしてマークされることなく適切に解決されるようにします。tsconfigDecoratorMetadata フラグは、SWC esbuildプラグインを有効にするかどうかを決定します。esbuildは emitDecoratorMetadata をサポートしていないため、SWCが事前変換として処理します。

ターゲットのフォールバックチェーンは次のとおりです。明示的な --target CLIフラグ → 設定ファイルの target → tsconfigの compilerOptions.target'node16'。つまり、tsconfig.jsones2020 が指定されていれば、追加設定なしでtsupはそれを使います。

Tip: tsupでパスエイリアスが期待どおりに動作しない場合は、tsconfig.jsonpaths が設定されているか確認しましょう。tsupはこれを直接読み込み、エイリアスされたimportが外部化されないよう利用しています。

defaultOutExtensionによるスマートな出力拡張子

package.jsontype フィールド、出力フォーマット、ファイル拡張子の関係は、混乱しやすいポイントです。tsupはこれを defaultOutExtension で自動的に処理します。

export function defaultOutExtension({
  format,
  pkgType,
}: {
  format: Format
  pkgType?: string
}): { js: string; dts: string } {
  let jsExtension = '.js'
  let dtsExtension = '.d.ts'
  const isModule = pkgType === 'module'
  if (isModule && format === 'cjs') {
    jsExtension = '.cjs'
    dtsExtension = '.d.cts'
  }
  if (!isModule && format === 'esm') {
    jsExtension = '.mjs'
    dtsExtension = '.d.mts'
  }
  if (format === 'iife') {
    jsExtension = '.global.js'
  }
  return { js: jsExtension, dts: dtsExtension }
}

対応表は以下のとおりです。

package.json type フォーマット JS拡張子 DTS拡張子
(なし/"commonjs" cjs .js .d.ts
(なし/"commonjs" esm .mjs .d.mts
"module" cjs .cjs .d.cts
"module" esm .js .d.ts
any iife .global.js .d.ts

ルールはシンプルです。.js はそのパッケージの「ネイティブ」なフォーマット(非moduleパッケージならCJS、moduleパッケージならESM)に使われます。もう一方のフォーマットを出力する場合は、明示的な拡張子(.mjs または .cjs)でNode.jsのモジュールシステムにフォーマットを伝えます。IIFEはNode.jsのモジュール解決で使われないため、常に .global.js になります。

outExtension オプションを使えばこの挙動を上書きできます。フォーマット、オプション、パッケージタイプを含む完全なコンテキストが渡されますが、デフォルトのままで大多数のケースは正しく処理されます。

次のステップ

オプションの正規化が完了すれば、ビルドを開始する準備が整います。第3回では runEsbuild() を掘り下げます。tsupが NormalizedOptions を完全なesbuild設定に変換する方法、6つのバンドル済みesbuildプラグイン、重要な write: false パターン、そして PluginContainer がソースマップのマージを伴うビルド後変換をどのように統制するかを解説します。