hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

“citty” “c12” を使って, 設定ファイルベースで Vite Server が立ち上がる CLI ツールを作ってみる

この記事は hacomono Advent Calendar 2025 の13日目の記事です

みゅーとんです.
アドベントカレンダー13日目, 私は今月2件目です.
なんで3件も枠取っちゃったんだろ. 何にせよ頑張って書いていきます.

はじめに

要件: 独自のツールチェインの必要性

詳細を明かすことはできませんが, 小さいフロントエンドのページを複数まとめてつくるような構成が要件にありました.この要件は, 以下の条件を含んでいました.

  • フレームワーク非依存: 特定のフレームワーク(React, Vue, Svelte など)に縛られず, ページごとに最適なものを選択したい
  • 単体ページ: ルーティングを含まず, それぞれが単一のページとして描画される
  • 統一された開発体験: フレームワークが違っても, 開発サーバーの立ち上げ方やビルド方法は統一したい
  • 統一されたデプロイ結果の構造: 使用するフレームワークに関わらず, 特定の規格に統一されたビルド成果物が要件にあった

つまり, 要件として独自のメタフレームワークに近いものを実装する必要がありました.

モダンフロントエンドのツールチェインらしい構成を目指す

昨今のフレームワークのように, 開発者は設定ファイル (hacomono.config.ts) を書くだけで, 実装したい構成を簡単に設定し, 同時にこの設定に準じた開発サーバーが立ち上げられる構成を目指しました.
この記事では, 以下の 3 つの技術要素をどう実装し, 組み合わせたかを紹介します:

  1. Config File: TypeScript で書ける型安全な設定ファイル
  2. CLI: 設定ファイルを読み込んで開発サーバーやビルドを実行するコマンド
  3. Dev Server: Vite をベースにしたカスタム開発サーバー
注意事項

独自のフレームワークとして実装していますが, その詳細については, 現時点ではまだ秘匿の情報となっているため, 本記事では触れません. この記事では, そのフレームワークを使いやすくするための CLI ツールの実装 に焦点を当てています.

実現したいこと

まず, どんな開発体験を目指したかを説明します.

ユーザー(開発者)視点での使い方

開発者は hacomono.config.ts という設定ファイルを書くだけで, カスタム要素を追加できます:

// hacomono.config.ts
export default defineConfig({
  // 小さいフロントエンドを複数用意する
  entries: [
    {
      key: 'page-a',
      src: './pages/a/index.html'
    },
    {
      key: 'page-b',
      src: './pages/b/index.html'
    }
  ],
  // 利用したいフレームワークのプラグインを好きに指定する
  plugins: [react()]
})

そして, CLI コマンドで開発サーバーを起動します:

$ haco dev
Server started. <http://localhost:3000>

これだけで, 設定ファイルに基づいた開発サーバーが起動します.

もしくは, 特定の規格にあわせたビルドを用意します:

$ haco build
Output: dist/page-a/index.html
Output: dist/page-b/index.html
技術的な要件

この開発体験を実現するために、以下の技術的要件がありました:

  • 設定ファイルの読み込み: TypeScript の設定ファイルを動的に読み込む
  • 設定の変更監視: 設定ファイルが変更されたら自動でサーバーを再起動
  • CLI の実装: devbuild コマンドを提供
  • 開発サーバー: Vite をベースに、カスタムルーティングを追加
  • 動的な Vite 設定: 設定ファイルの内容から Vite の設定を生成

これらをどう実現したかを、順に見ていきます。

技術要素の選定と実装

1. Config File: c12 による設定ファイルの読み込み

なぜ c12 を選んだか

設定ファイルの読み込みには、UnJS の c12 を採用しました。選定理由は以下の通り:

  • TypeScript ファーストの設定: .ts ファイルを動的に読み込める
  • 型安全性: createDefineConfig で型付きヘルパーを提供できる
  • ファイル変更監視: watchConfig API で設定の変更を検知できる
  • 複数の設定ソースに対応: ファイル, 環境変数, デフォルト値などを統合できる
実装: 型安全な設定ファイルと, その読み込み

実装は以下の通りシンプルにできます. defineHacomonoConfig は c12 が提供する createDefineConfig を, ファイルの読み込みは loadConfigFile を使えば一発.

// src/config.ts
import { createDefineConfig, loadConfig as loadConfigFile } from 'c12'
import type { HacomonoConfig } from '@my-framework/schema'
// ユーザーが設定ファイルで使うヘルパー
export const defineHacomonoConfig = createDefineConfig<HacomonoConfig>()
// デフォルト設定
function defaultConfig(): HacomonoConfig {
  return {
    cwd: process.cwd(),
    dist: 'dist',
  }
}
// 設定ファイルを読み込む
export async function loadConfig(): Promise<HacomonoConfig> {
  const { config } = await loadConfigFile<HacomonoConfig>({
    name: 'hacomono',  // hacomono.config.ts を探す
    defaults: defaultConfig(),
  })
  return config
}

これにより、ユーザーは型補完が効いた状態で設定を書けます:

// ユーザーのプロジェクトの hacomono.config.ts
import { defineHacomonoConfig } from '@my-framework/cli'
export default defineHacomonoConfig({
  entries: [ /* 型補完が効く */ ],
})
設定の変更監視

watchConfig を使うことで, 設定ファイルが変更されたときに処理を実行できます. vite などのサーバーのライフサイクルにこれを組み合わせると, config の更新にあわせてサーバーを立ち上げ直すことができます.

import { watchConfig as watchConfigFile } from 'c12'
export async function watchConfig(options) {
  return watchConfigFile<HacomonoConfig>({
    name: 'hacomono',
    defaults: defaultConfig(),
    ...options,  // onUpdate コールバックを受け取る
  })
}
2. CLI: citty によるコマンド定義
なぜ citty を選んだか

CLI の実装には、UnJS の citty を採用しました:

  • TypeScript ファースト: 型安全にコマンドを定義できる
  • サブコマンドのサポート: dev, build などを簡単に定義できる
  • 引数のパースとバリデーション: 自動で引数をパースし, 型チェックしてくれる
  • 軽量: 依存が少なく, 起動が速い
  • エコシステム: 選定予定の技術が vite, c12 であったため, エコシステムとして近いものを採用したかった
package.json での bin の定義

まず、CLI コマンドを npm パッケージとして提供するために、package.json で bin を定義します:

{
  "name": "@my-framework/cli",
  "bin": {
    "haco": "./dist/cli.mjs"
  }
}

これにより、パッケージをインストールすると haco コマンドが使えるようになります。

実装: dev と build コマンド
// src/cli.ts
import { defineCommand, runMain } from 'citty'
import { createServer, build } from '@my-framework/builder'
import { loadConfig, watchConfig } from './config'
export const commands = defineCommand({
  meta: {
    name: 'haco',
    version: '0.0.0',
    description: 'Framework CLI',
  },
  subCommands: {
    dev: {
      meta: {
        name: 'dev',
        description: 'Run development server',
      },
      args: {
        verbose: {
          type: 'boolean',
          description: 'Verbose output',
          default: false,
        },
      },
      run: async ({ args }) => {
        // 設定を読み込む
        const config = { ...await loadConfig(), ...args }
        // 開発サーバーを作成
        let server = await createServer(config)
        // 設定ファイルの変更を監視
        await watchConfig({
          onUpdate: async ({ newConfig }) => {
            console.log('Config changed, restarting server...')
            server.close()
            server = await createServer(newConfig)
            await server.listen()
          },
        })
        // サーバーを起動
        await server.listen()
      },
    },
    build: {
      meta: {
        name: 'build',
        description: 'Build inside views',
      },
      run: async () => {
        const config = await loadConfig()
        await build(config)
      },
    },
  },
})
runMain(commands)

ポイント:

  • 設定ファイルの変更を監視し, 変更時にサーバーを自動で再起動
  • args で CLI 引数を受け取り, 設定にマージ

3. Dev Server: Vite をベースにしたカスタム開発サーバー

なぜ Vite を選んだか

開発サーバーのベースには Vite を採用しました:

  • 高速な HMR: ファイル変更時の再読み込みが高速
  • プラグインシステム: configureServer フックでカスタムミドルウェアを追加できる
  • マルチエントリーのビルド: 複数のエントリーポイントを一度にビルドできる
  • HTML の変換: transformIndexHtml で HTML に自動でスクリプトを注入できる
  • フレームワークプラグイン: 要件としてフレームワークへの依存部分は vite plugin が担う方針とした
実装: Vite Plugin でカスタムミドルウェアを注入
// packages/builder/src/server/server.ts
import { createServer as createViteServer } from 'vite'
// Vite Plugin を作成
function serverPlugin(config: HacomonoConfig) {
  return {
    name: 'hacomono-dev-server',
    configureServer(server) {
      // Vite のミドルウェアスタックにカスタムミドルウェアを注入
      server.middlewares.use(async (req, res, next) => {
        // entries から該当するエントリーを探す
        const entry = config.entries?.find(e => req.url === `/${e.key}`)
        if (entry) {
          // エントリーの HTML ファイルを読み込む
          const html = await readFile(entry.src, 'utf-8')
          // Vite で HTML を変換 (HMR クライアントを注入)
          const transformed = await server.transformIndexHtml(req.url, html)
          res.setHeader('Content-Type', 'text/html')
          res.end(transformed)
          return
        }
        next()
      })
    },
  }
}
// 開発サーバーを作成
export async function createServer(config: HacomonoConfig) {
  const viteConfig = {
    plugins: [serverPlugin(config)],
    server: {
      host: config.dev?.host ?? 'localhost',
      port: config.dev?.port ?? 3000,
    },
  }
  return await createViteServer(viteConfig)
}

ポイント:

  • configureServer フックでカスタムミドルウェアを注入
  • 設定ファイルの entries から動的にファイルを読み込み
  • transformIndexHtml で HMR クライアントを自動注入
重要な API: transformIndexHtml

Vite の transformIndexHtml は, HTML を変換するための API です. これにより, 開発時に HMR 用のスクリプトが自動で注入されます:

const rawHTML = await readFile(entry.src, 'utf-8')
// Vite が HTML を変換: HMR クライアント、モジュールプリロードなどを注入
const transformedHTML = await server.transformIndexHtml(
  event.path,  // 現在のパス (相対パス解決に使われる)
  rawHTML      // 元の HTML
)

これにより、ユーザーが書いた HTML に以下のようなスクリプトが自動で追加されます:

<!-- 元の HTML -->
<html>
  <head>
    <script type="module" src="/main.js"></script>
  </head>
  <body>...</body>
</html>
<!-- 変換後の HTML (HMR クライアントが注入される) -->
<html>
  <head>
    <script type="module" src="/@vite/client"></script>
    <script type="module" src="/main.js"></script>
  </head>
  <body>...</body>
</html>

4. Build, Bundle: Vite でビルドする

実装: 動的な Vite 設定の生成

設定ファイルから Vite の設定を動的に生成することで, ユーザーが定義したエントリーポイントをすべてビルドできます:

// src/shared/config.ts
export function toCommonViteConfig(config: FrameworkConfig): ViteConfig {
  // 設定から entries を抽出
  const entries = config.entries ?? []
  return defineConfig({
    base: '/',
    root: config.src ?? '.',
    build: {
      outDir: config.dist ?? './dist',
      rollupOptions: {
        // エントリーポイントを動的に設定
        input: entries.map((entry) => entry.src).filter((src) => src.endsWith('.html')),
      },
    },
  })
}

マルチエントリーのビルド: 設定ファイルの entries から, Vite の rollupOptions.input を動的に生成することで, 複数のエントリーポイントを一度にビルドできます.

3 つの技術要素の統合

これまで説明した 3 つの技術要素がどう連携するかを以下にざっくりまとめました.

┌─────────────────────────────────────────────────────────┐
│ ユーザー                                                 │
└────────────┬────────────────────────────────────────────┘
             │
             │ $ haco dev
             ▼
┌─────────────────────────────────────────────────────────┐
│ CLI (citty)                                             │
│  - コマンドをパース                                       │
│  - 設定ファイルを読み込み (c12)                            │
└────────────┬────────────────────────────────────────────┘
             │ loadConfig()
             ▼
┌─────────────────────────────────────────────────────────┐
│ Config File (c12)                                       │
│  - hacomono.config.ts を読み込み                         │
│  - 型安全な設定オブジェクトを返す                          │
│  - 設定の変更を監視 (watchConfig)                         │
└────────────┬────────────────────────────────────────────┘
             │ createServer(), build()
             ▼
┌─────────────────────────────────────────────────────────┐
│ Dev Server, Builder (Vite)                              │
│  - Vite の設定を動的に生成                                │
│  - Vite Plugin でカスタムミドルウェアを注入                │
│  - 設定に基づいたカスタム処理を実行                        │
│  - Vite に JS/CSS の配信を任せる                          │
└─────────────────────────────────────────────────────────┘


技術的な懸念点

Vite との密結合

今回の実装は Vite の API に強く依存しており, 以下のような懸念があります:

  • Vite バージョンアップへの追従コスト
  • 他のビルドツールへの切り替えが困難
トレードオフの判断

一方で、Vite との密結合には以下のメリットもあります:

  • 開発速度: Vite のエコシステムをフル活用でき, 実装がシンプルになる
  • HMR の恩恵: Vite の高速な HMR をそのまま利用できる
  • プラグインエコシステム: Vite の豊富なプラグインを活用できる

現時点では, 開発速度と DX を優先して Vite との密結合を許容していますが, 将来的には以下のような対応が考えられます:

  • ビルドツールを抽象化するレイヤーを設ける(Adapter パターン)
  • Vite の安定した Public API のみを使用するようリファクタリング
  • 他のビルドツールへの移行パスを用意

また, Adapter パターンを初期から取り入れる策として, unplugin を使っておくべきだったと思います.

まとめ

Config File, CLI, Dev Server の 3 つの技術要素を組み合わせて, 設定ファイルベースの開発フレームワークを実装しました.

技術選定:

  • c12: TypeScript の設定ファイルを動的に読み込み、変更を監視
  • citty: 型安全な CLI を簡単に構築
  • Vite: プラグインシステムで自由な技術選定, カスタマイズ可能な開発サーバー

実装のポイント:

  • Vite の configureServer フックでカスタムミドルウェアを注入
  • transformIndexHtml で HTML に HMR クライアントを自動注入
  • watchConfig で設定変更を検知し、サーバーを自動再起動
  • 設定から Vite の rollupOptions.input を動的に生成


UnJS エコシステム (c12, citty) と Vite を組み合わせることで, 型安全で DX の高いフレームワークを少ないコードで実装できました. フレームワークを作る際の参考になれば幸いです.