ブログ一覧

defineConfig 系ヘルパーは identity とは限らない — 剥がすとビルドは通るのに機能が静かに死ぬ

Vite の defineConfig に代表される「define なんとか」というヘルパー関数は、型推論のためだけの identity 関数(受け取ったオブジェクトをそのまま返す関数)であることが多いです。

// Vite の defineConfig は実質これだけ
export function defineConfig(config: UserConfig): UserConfig {
	return config;
}

この経験則があるため、フレームワークのプラグイン API にある definePlugin のようなヘルパーも「どうせ identity だろう」と思い込みやすい。そして、共有パッケージからフレームワーク依存を切り離したいときなどに、import { definePlugin } from "framework" を外して (d) => d 相当のインライン実装に置き換えたくなります。

本記事では、それをやるとビルドも型チェックもデプロイも全部成功するのに、機能だけが静かに死ぬケースがあること、そしてなぜ静的チェックでは検出できないのかを、実際に確認した CMS フレームワークのプラグイン機構を例にまとめます。

前提: 例として挙げる挙動は特定の CMS フレームワーク(ここでは some-cms と呼びます)のプラグイン機構で確認したものですが、「define* ヘルパーが実行時に検証・正規化を行う」パターン自体は、どのフレームワークでも起こりえます。

罠その1: grep で見つかる identity 実装が「本物」とは限らない

「definePlugin は identity か?」を確かめようとして node_modules を grep すると、こういうコードが見つかることがあります。

export const definePlugin = (d) => d;

「ほら、やっぱり identity だ」と結論づけると間違えます。これは some-cms の CLI がプラグインのマニフェスト抽出用に一時生成するシム(本物の代わりに差し込まれる軽量スタブ)で、ランタイムで使われる実装ではありませんでした。

本物は別のバンドルチャンクにいて、中身はこうなっています(簡略化)。

function definePlugin(definition) {
	// 1. id の形式検証(lowercase + ダッシュ、または @scope/name)
	// 2. version の semver 検証
	// 3. capability 名の検証
	for (const cap of capabilities) {
		if (!validCapabilities.has(cap)) throw new Error(`Invalid capability "${cap}"`);
	}
	// 4. capability の正規化(レガシー名 → 正準名)
	const canonical = normalizeCapabilities(capabilities);
	// 5. write 系 capability に対応する read 系を暗黙補完
	if (canonical.includes("content:write") && !canonical.includes("content:read")) {
		normalizedCapabilities.push("content:read");
	}
	// 6. hooks をプラグイン id と紐付けて解決
	return { id, version, capabilities: normalizedCapabilities, hooks: resolveHooks(hooks, id), ... };
}

identity どころか、バリデーションと正規化の塊です。

罠その2: capability の「改名マップ」は実行時にしか存在しない

上のステップ 4 が今回の主役です。some-cms は capability 名の世代交代を、define* ヘルパー内の改名マップで吸収していました。

const CAPABILITY_RENAMES = Object.freeze({
	"read:content": "content:read",
	"write:content": "content:write",
	"email:provide": "hooks.email-transport:register",
	// ...
});

つまりプラグインが宣言している "email:provide"レガシー別名で、ランタイムが実際に見ているのは正準名の "hooks.email-transport:register" です。この変換は definePlugin を通ったときにだけ行われます。

ここで definePlugin を identity に置き換えるとどうなるか。

  1. プラグインの capability はレガシー名 "email:provide" のまま登録される
  2. ランタイムは正準名しか見ないので、「このプラグインはメール配送の capability を持っていない」と判断する
  3. メール配送フックが登録されない
  4. フォーム送信や認証メールなどの通知が、エラーひとつ出さずに送られなくなる

恐ろしいのは 4 です。フックが「登録されない」ことは例外ではなく、ただの不在です。呼ぶ側から見れば「配送先プロバイダがいないので何もしない」という正常系のパスを通るだけで、ログにも痕跡が残りません。

なぜ静的チェックで検出できないのか

この壊れ方は、普段頼っているチェックを全部すり抜けます。

  • 型チェック: identity 実装でもオブジェクトの形は正しいまま。型はレガシー名も許容している(後方互換のため union に残っている)ので、エラーになりません。
  • ビルド: import が解決でき、バンドルが生成できれば成功します。capability 名の意味論はビルドの関心事ではありません。
  • デプロイ: 成果物が正常に配置されれば成功します。
  • 隣接機能のテスト: たとえばフォーム送信の「保存」は別プラグインの責務なので、送信テストをしても保存は成功します。死んでいるのは通知だけです。

「値の変換が実行時にしか起きず、その変換の欠落が例外ではなく機能の不在として現れる」— この組み合わせが、緑のパイプラインの下で機能を殺します。

対策

① define* ヘルパーを剥がさない・自作しない

フレームワークが提供する define* ヘルパーは、identity に見えてもそのフレームワークのバージョンと結合した ABI の一部として扱うのが安全です。「依存を切りたい」という動機でインライン化するのは、正規化ロジックのフォークを自前で保守すると宣言するのと同じです。

② 依存バージョンの混在が動機なら、pnpm の injected dependencies を使う

define* を剥がしたくなる典型的な動機は「共有パッケージが peer のフレームワークを単一バージョンに解決してしまい、モノレポ内のバージョン混在で壊れる」ことです。これはヘルパーの除去ではなく、dependenciesMeta.<pkg>.injected: trueアプリごとに peer を独立解決させるのが正解です。詳しくは別記事「pnpm monorepo で共有パッケージを複数バージョンの peer に対応させる(injected dependencies)」にまとめています。

③ 通知・配送系は「実際に届いたか」をランタイムで検証する

この類の機能は、ビルドの成否と生死が連動しません。依存の更新やプラグイン構成の変更をしたら、本物のイベントを 1 回流して、届くところまで観測するのを習慣にするのが確実です。Cloudflare Workers なら `wrangler tail` で本番ログを流しながらテスト送信し、配送フックの発火ログを目視する、という手順で数分で済みます。

まとめ

  • define* ヘルパーは identity 関数とは限らない。実行時の検証・正規化(capability の改名など)を担っていることがある
  • grep で見つけた identity 実装は、CLI が生成するシムかもしれない。実装を根拠に判断するなら、ランタイムで実際に使われるコードパスを確認する。
  • 正規化の欠落は例外ではなく「フックの不在」として現れるため、型チェック・ビルド・デプロイの成功では検出できない。通知・配送系はランタイムで実際の到達を検証する。
  • バージョン混在が動機なら、ヘルパーを剥がすのではなく injected dependencies で解決する。

参考リンク