
この記事は hacomono Advent Calendar 2025 の13日目の記事です
みゅーとんです.
アドベントカレンダー13日目, 私は今月2件目です.
なんで3件も枠取っちゃったんだろ. 何にせよ頑張って書いていきます.
はじめに
要件: 独自のツールチェインの必要性
詳細を明かすことはできませんが, 小さいフロントエンドのページを複数まとめてつくるような構成が要件にありました.この要件は, 以下の条件を含んでいました.
- フレームワーク非依存: 特定のフレームワーク(React, Vue, Svelte など)に縛られず, ページごとに最適なものを選択したい
- 単体ページ: ルーティングを含まず, それぞれが単一のページとして描画される
- 統一された開発体験: フレームワークが違っても, 開発サーバーの立ち上げ方やビルド方法は統一したい
- 統一されたデプロイ結果の構造: 使用するフレームワークに関わらず, 特定の規格に統一されたビルド成果物が要件にあった
つまり, 要件として独自のメタフレームワークに近いものを実装する必要がありました.
モダンフロントエンドのツールチェインらしい構成を目指す
昨今のフレームワークのように, 開発者は設定ファイル (hacomono.config.ts) を書くだけで, 実装したい構成を簡単に設定し, 同時にこの設定に準じた開発サーバーが立ち上げられる構成を目指しました.
この記事では, 以下の 3 つの技術要素をどう実装し, 組み合わせたかを紹介します:
- Config File: TypeScript で書ける型安全な設定ファイル
- CLI: 設定ファイルを読み込んで開発サーバーやビルドを実行するコマンド
- 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 の実装:
devとbuildコマンドを提供 - 開発サーバー: Vite をベースに、カスタムルーティングを追加
- 動的な Vite 設定: 設定ファイルの内容から Vite の設定を生成
これらをどう実現したかを、順に見ていきます。
技術要素の選定と実装
1. Config File: c12 による設定ファイルの読み込み
なぜ c12 を選んだか
設定ファイルの読み込みには、UnJS の c12 を採用しました。選定理由は以下の通り:
- TypeScript ファーストの設定:
.tsファイルを動的に読み込める - 型安全性:
createDefineConfigで型付きヘルパーを提供できる - ファイル変更監視:
watchConfigAPI で設定の変更を検知できる - 複数の設定ソースに対応: ファイル, 環境変数, デフォルト値などを統合できる
実装: 型安全な設定ファイルと, その読み込み
実装は以下の通りシンプルにできます. 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 の高いフレームワークを少ないコードで実装できました. フレームワークを作る際の参考になれば幸いです.