zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

プラグイン

zfb プラグインの作成と利用 — 5 つのライフサイクルフック、仮想モジュール、インポートエイリアス、注入ルート、client entry。

zfb プラグインは、デフォルトエクスポートが ZfbPlugin オブジェクトである素の ES モジュールです。zfb の build、dev、preview の各ホストは、各プラグインモジュールを起動時に一度だけロードし、5 つのオプションなライフサイクルフックをそこにディスパッチします。プラグインは 5 つのうち任意の部分集合だけを宣言できます。省略したものは暗黙的に何もしません。

このページはプラグイン作成者向けのコントラクトを説明します。対応する API リファレンスは defineConfig.plugins にあります。

プラグインホストが起動するのは、実際にプラグインコードを実行しうるコマンド — zfb buildzfb devzfb preview — だけです。zfb check はプラグインホストを決して起動しません。zfb check は純粋な lint コマンド(tsc --noEmit とコンテンツコレクションのスキーマ検証)であり、zfb check の実行中はプラグインの setuppreBuild などが走ることはありません。

プラグインが本当に必要になるとき

setup が提供する 4 つの機能のいずれかが必要になったときにプラグインを使ってください。以下のセクションで説明するのと同じ 4 つです。どれにも当てはまらない場合は、素のスクリプトのほうが適した道具です(後述の 必要ないとき — レシピを書く を参照)。エンジン対スクリプトという広い観点でのメンタルモデルについては 設計哲学 を参照してください。

  • 合成データソースを裏付ける仮想モジュール。 ページがディスク上に実ファイルを持たない specifier から import する必要がある場合 — メタデータ DB、コンテンツインデックス、生成された設定ブロブなど — そのソースをモジュールグラフに注入する唯一の手段が addVirtualModule です。例: 全ページにわたる import metadata from "virtual:metadata-db"。loader がそのソースを実ファイルから組み立てるなら、登録時の watchFiles オプションでそれらのファイルを挙げておくと、変更されたときに zfb dev が loader を再実行します。

  • すべてのバンドラに適用しなければならないエイリアス書き換え。 addAlias は完全一致のインポート書き換えを登録し、これは 3 つすべての利用者(組み込み V8 ホスト、メインのページ/レイアウトバンドラ、islands esbuild バンドラ)が尊重します。tsconfig.jsonpaths エントリは型チェッカにしか届きません。エイリアスを 3 つすべてのバンドラで実行時に効かせる必要があるなら、それはプラグインの領域です。

  • pages/ の外に存在する注入ページルート。 injectRoute は、URL パターンの下に ESM ページモジュールを登録します。entrypoint はソース TSX/TS モジュールでも、パッケージが公開するコンパイル済み ESM .js モジュールでも構いません。build と dev のどちらでも、ページパイプラインが一致する静的 / 動的な注入ルートをレンダリングします。そのルートが本当に dev 専用のときだけ、command === "dev" で呼び出しをガードしてください。

  • Package-owned client entry。 addClientEntry は、プロジェクトの client scripts と一緒にバンドルされる *.client.{ts,tsx,js,jsx} の side-effect entry を登録します。

ページではないレスポンスには、別の devMiddleware フック(または preview 版の previewMiddleware — 後述)を使ってください。JSON API エンドポイント、ホットリロードのブリッジ、アップロードハンドラなどです。JSX パイプラインもページレンダラもなく、ただ { status, headers, body } を返す関数です。

5 つのフック

import { definePlugin } from "@takazudo/zfb/plugins";

export default definePlugin({
  name: "my-plugin",
  setup?(ctx) {},            // #255 — runs once at host boot, before preBuild
  preBuild?(ctx) {},         // file-generation work before the bundler / renderer
  postBuild?(ctx) {},        // finalisation after dist/ has been written
  devMiddleware?(ctx) {},    // per-request HTTP handlers in `zfb dev`
  previewMiddleware?(ctx) {},// per-request HTTP handlers in `zfb preview`(静的モード、#1542)
});

フックは、zfb.config.tsplugins 配列にプラグインが現れる順序で逐次実行されます。いずれかのフックで throw されるとビルド(または dev / preview の起動)が中断し、throw したフックとプラグイン名がエラーバナーに表示されます。

プラグインからのログ出力

ctx.logger(すべてのフックコンテキストで利用可能)とグローバルな console オブジェクトは、いずれも zfb dev / zfb build / zfb preview のターミナルに、それを出力したプラグインの名前が付いた形でレンダリングされます。

zfb <level>: [plugin:<name>] <message>
  • ctx.logger.info(msg).warn(msg).error(msg) は、それぞれ宣言どおりのレベルでレンダリングされます。

  • console.log.info.dir.table など console の stdout 側のメソッドはレベル info でレンダリングされます。console.warn.error.trace.assert など stderr 側のメソッドはレベル error でレンダリングされます — warn では ありません。これは Node 自身の Console API の仕様に従うもので、そこにはレベルごとの出力先ではなく stdout と stderr の 2 つの出力ストリームしかありません。実際に warn レベルの行が必要なら、console.warn(...) ではなく ctx.logger.warn(...) を呼んでください。

  • 出力の帰属先は、その呼び出しが実行された時点で実際にスタック上にあるプラグイン(await された処理の内部を含む)に従います。そのため、重なり合うフック呼び出しや同時並行の devMiddlewarepreviewMiddleware のリクエストハンドラも、それぞれ正しいプラグインに帰属し、混ざることはありません。どのプラグインのコードの外(ホストのブートストラップ時)で出力されたログ行は plugin-host に帰属します。

console を経由しないプラグインホストサブプロセス自身の stderr への生の書き込み(ネイティブ依存ライブラリからの出力など)や、ホストが解析できなかった行は、帰属させるプラグインがないため、代わりに zfb warn: [plugin-host stderr] <line> / zfb warn: [plugin-host stdout] <line> としてレンダリングされます。

プラグインの entry ファイル — .ts.tsx.mts.cts のサポート

拡張子が .ts.tsx.mts.cts のいずれかであるプラグインの name entry は、zfb buildzfb devzfb preview のいずれでも、ロードされる前に zfb 自身の pinned esbuild でバンドルされます。これにより、.ts プラグイン entry では次のことが可能です。

  • 複数ファイルに分割できる。 ./helper.js という specifier が、兄弟の helper.ts ソースファイルに解決される場合も含みます。

  • TypeScript の完全な構文を使える。 enumnamespace、コンストラクタのパラメータプロパティは、いずれも正しくバンドル・変換されます。

  • .tsx モジュールを import できる。 JSX ごとです。

  • tsconfig の paths エイリアスを使える。 esbuild は entry の実際のディスク上の位置から上に向かって walk し、プラグインプロジェクト自身の tsconfig.json を自動検出するため、エイリアス解決はそのまま機能します。

.js.mjs.cjs の entry は影響を受けません — これまでどおり、バンドルなしでそのままロードされます。bare package specifier(例: name: "@acme/my-plugin")は、そのパッケージの exports/main.ts 系ファイルに解決される場合にのみバンドルされます。実際には、公開パッケージがこの形になることはほぼありません。

.ts プラグイン entry については、tsx/esm/api の register/unregister-per-hook loader シムによる回避策はもう不要です — バンドルがそれを完全に置き換えます。

残っている制限

  • .js/.mjs の entry 自体が .ts の隣接ファイルを import している場合、その entry はバンドルされません — バンドルするかどうかを決めるのは entry 自身の拡張子だけです。この import は、これまでどおりの素の Node の挙動のままです。つまり、新しいバージョンの Node で strip-only 互換な TypeScript 構文に対してのみ動作し、node_modules の下では決して解決されません。これに当たった場合は、entry 自体を .ts にリネームしてください。

  • バンドルされたプラグインの内部で、静的なテンプレートプレフィックスを持つ動的な import() 呼び出し(例えば import(`./mods/${name}.mjs`))は、起動時に存在するファイルに固定されます — esbuild は、実行時に import を解決するのではなく、マッチしたファイルをバンドルへ glob-inline します。ステージされたバンドル自身の import.meta.url は、元のソースパスではなくステージされたファイルを指しますが、dirname() 相対の読み取りは引き続き正しく動作します(バンドルは entry 自身のディレクトリにステージされます)。

Node バージョンの下限

プラグインのバンドルは esbuild を --target=node22 で呼び出します。その結果として、.ts プラグイン entry はもはや Node 組み込みの型ストリッピングにまったく依存しなくなり、Node 22.0–22.17 の崖を型ストリッピングの問題としては解消します。これは実際の Node 22.17.0 で検証済みです(4 つのバンドル失敗ケースすべて — 兄弟の .js.ts 解決、enum/namespace/パラメータプロパティ、.tsx の import、tsconfig paths エイリアス — がバンドル済み出力から正しく実行されました。代替案の --target=esnext も試しましたが、そこでは using 宣言の反例により不採用と判断しました)。これは Node 22.0 自体での実行テストを構成するものではありません — 22.0 という下限は、esbuild 自身の node22 ダウンレベリング契約に基づくものであり、その正確な Node バージョンに対して実行したテストに基づくものではありません。

.tsx プラグイン entry における JSX

プラグインプロジェクト自身の tsconfig.json が JSX(jsx / jsxImportSource)を制御し、paths エイリアスと同じように entry のディスク上の位置から自動検出されます。tsconfig が存在しない場合は、esbuild のデフォルトである classic transform(React.createElement)が適用されます。異なる JSX ランタイムが必要な .tsx プラグイン entry は、JSX モードを宣言する自身の tsconfig を用意するか、互換性のある factory を明示的に import する必要があります。

バンドルエラー

コンパイルの失敗(entry 自身のソースが不正、またはその import のいずれかが解決できない場合)は、plugin bundling: esbuild failed for plugin `{name}` … に続けて、esbuild 自身の診断情報(file:line:col を含む)として現れます。それ以外のバンドル準備段階の問題(esbuild 自体が見つからない、entry のディレクトリが書き込み不可、esbuild サブプロセスの起動失敗やタイムアウトなど)は、esbuild の診断情報を伴わない、それぞれ別個の plugin bundling: … メッセージとして現れます。ロード時や setup/ライフサイクルフックの実行中にコードが throw するプラグインは、これまでどおりお馴染みのプラグイン init/フックエラーとして現れます — バンドルの失敗と実行時の失敗は、区別して報告されます。

ステージされたバンドルの置き場所

.ts/.tsx/.mts/.cts プラグイン entry は、.zfb-plugin-bundle-<random>.mjs という名前の一時ファイルにバンドルされ、entry 自身のディレクトリにステージされます — 共有のシステム一時ディレクトリではありません。この配置は付随的なものではなく、正しく動作するための前提条件です。バンドルは esbuild の --packages=external フラグ付きでビルドされるため、あらゆる bare import(from "some-npm-package")はバンドル時には未解決のまま残され、代わりにロード時に Node 自身の import() によって、ステージされたファイル自身の位置import.meta.url)から祖先の node_modules ディレクトリを辿って解決されます — これは元の、バンドルされていない entry が行っていたのとまったく同じ walk です。entry の隣にステージすることで、プロジェクトローカルのプラグイン(依存関係がプロジェクトの node_modules に hoist されている場合)とパッケージプラグイン(自身のネストした node_modules を持つ場合)のどちらでも、この解決が entry 本来のものと同一に保たれます。

ステージされたファイルは、それを作ったプラグインホストがシャットダウンすると自動的に削除されます。その機会を得る前に zfb プロセスが kill された場合(SIGKILL、デバッガセッションの中断など)、.zfb-plugin-bundle-*.mjs の迷子ファイルが残ることがあります。同じディレクトリに次に .ts/.tsx/.mts/.cts プラグインバンドルがステージされる際、zfb は以前の実行が残した迷子ファイルを自動的に掃除します。掃除対象となるのは、60 秒より古く、かつ存命のプロセスによるアドバイザリロックを保持していないファイルだけです — たとえ 60 秒より古くても、ホストが一時停止しているだけで実際にバンドル中のファイルは決して触れられません。

既存プロジェクトでは gitignore に追記する

新規にスキャフォールドされたプロジェクトは、ステージされたプラグインバンドルを自動的に無視します。このグロブが導入される前からあるプロジェクトでは、.gitignore**/.zfb-plugin-bundle-*.mjs を追加してください — プラグインのソースは任意の深さに置かれうるため、このグロブはルート起点ではありません(Styling — Tailwind が見る場所 で説明している Tailwind の entry ファイル用グロブと同じ考え方です)。

setup — 仮想モジュール、エイリアス、注入ルート、client entry の登録

zfb build ホストの起動ごとに 一度zfb dev ホストの起動ごとに一度、そして zfb preview(静的モード)ホストの起動ごとに一度、preBuild の前に実行されます。このフックは、プラグインがモジュール解決パイプライン(仮想モジュール + インポートエイリアス)、合成ページルート、package-owned client entry に寄与する場所です。setup が完了すると、レジストリはその実行の残りの期間中、凍結されます。以降に走るものが登録を追加したり、削除したり、差し替えたりすることはできません。

凍結されるのは登録であって、登録が生み出すものすべてではありません。watchFiles を宣言した仮想モジュールは、zfb dev 中にそのファイルのいずれかが変更されると loader が再実行されます。ただし、それが属するレジストリのエントリ自体は setup が作ったままです。後述の addVirtualModule を参照してください。

zfb preview では、setup は最小限の非 V8 経路で実行されます — preview は再ビルドを行わないため preBuild はそこでは決して実行されません。また addAlias/addVirtualModule/injectRoute/addClientEntry の呼び出しは形の一貫性のために受け付けられますが、無効(inert) です。preview はすでにビルド済みの dist/ をそのまま配信するだけで、これらのレジストリが供給する scan → bundle → render パイプラインに再突入することは決してありません。"preview" の下で意味を持つのは、フック自身の副作用(例えば previewMiddleware 用の設定を計算するためにファイルを読むなど)と previewMiddleware の登録そのものだけです。

setup({
  command,
  projectRoot,
  config,
  options,
  logger,
  addAlias,
  addVirtualModule,
  injectRoute,
  addClientEntry,
}) {
  // `command` is "build", "dev", or "preview". Gate dev-only registrations on it.
  addAlias("@/components/foo", "./src/components/foo.tsx");
  addVirtualModule("virtual:my-data", () =>
    `export default ${JSON.stringify(myJson)}`,
  );
  injectRoute("/preset-page", "./preset/page.tsx");
  addClientEntry("./client/analytics.client.ts");
  if (command === "dev") {
    injectRoute("/dev/preview", "./scripts/dev-preview.tsx");
  }
}

addAlias(from, to) — 完全一致のインポート書き換え

完全に一致したときに to へ解決される、単一のインポート specifier を登録します。パスはプロジェクトルートを基準に結合されます。

addAlias("@/components/foo", "./src/components/foo.tsx");

これにより、import Foo from "@/components/foo"./src/components/foo.tsx に解決されます。

サブパスのインポートは一致しません。import "@/components/foo/bar" は書き換えられず、バンドル時に未解決インポートエラーとして現れます。3 つすべての利用者(SSR と paths() 評価を駆動する組み込み V8 ホスト、メインのページ/レイアウトバンドラ、クライアントサイドの "use client" バンドルを生成する islands esbuild バンドラ)は、同じ完全一致のコントラクトを尊重します。

衝突検出。 2 つのプラグインが同じ from を異なる to で登録すると AliasConflict が発生し、両方の問題プラグインを名指ししてビルドを中断します。冪等な再登録(同じプラグイン、同じ to)は許可されます。

addVirtualModule(specifier, loader, options?) — 合成モジュールソース

ソーステキストが loader によってオンデマンドで生成される bare specifier を登録します。推奨されるプレフィックスは virtual: ですが強制ではありません。実モジュールの specifier と衝突しないものなら何でも使えます。

addVirtualModule("virtual:metadata-db", () =>
  `export default ${JSON.stringify(buildMetadataIndex())}`,
);

loader完全な ESM ソーステキスト を文字列として返します。バンドラ/組み込み V8 ホストは、返された文字列をそのモジュールのソースとしてそのまま使います。loader は 遅延実行ではなく、即時に実行されますzfb build の実行ごとに 1 回、zfb dev のホスト起動ごとに 1 回、すべてのプラグインの setup フックが返り終えた直後の setup フェーズで実行されます。登録された specifier がどのページ/モジュールからも import されなくても実行されます。生成されたソースはメモ化され、以降その specifier を import するたびに再利用されます。(zfb preview の下では、この登録は形の一貫性のために受け付けられますが無効です — 前述の setup セクションを参照してください — そのため loader はそこでは決して実行されません。)

loader のコントラクトは 1 つ だけです。「loader が JSON を返し zfb がそれをラップする」という代替モードはありません。JSON を公開したいなら、自分で () => "export default " + JSON.stringify(data) としてください。

options.watchFileszfb dev 中にメモ化を更新する。 このメモ化は、ディスク上の実ファイルを読む loader が zfb dev で必ずぶつかる壁です。loader は起動時に一度走ったきりなので、そのファイルを編集してもページが import する内容は変わりません。loader が読むファイルを登録時に宣言しておくと、これを解消できます。

const dataFile = join(projectRoot, "data/metadata.json");

addVirtualModule(
  "virtual:metadata-db",
  () => `export default ${readFileSync(dataFile, "utf-8")}`,
  { watchFiles: [dataFile] },
);

zfb dev はこれらのパスを監視します。いずれかが変更されると、その loader だけ がメモ化をバイパスして再実行され、メモ化されたソースが新しい結果で置き換わり、再レンダリングされます。ライブリロードが届けるページは、編集後のファイルから組み立てられたものになります。宣言したファイルが変更されなかった loader は、再実行されることはありません。

  • 絶対パスのみ。 相対パスや空文字のエントリは setup の時点で throw し、起動を中断します。エントリは dev ウォッチャーへそのまま渡され、プロジェクトルートを基準に解決されることは決してないため、相対パスには解決の起点がありません。extraWatchPaths と同じ理由による、同じルールです。

  • エントリはファイルでなければならない — ディレクトリは拒否される。 zfb は watchFiles のエントリを、そのディレクトリへの非再帰的な監視として登録し、正確なパスでマッチさせます。そのため、ディレクトリを指すエントリの下で実際に何かが観測されることは決してありません。黙って無効な登録になるのではなく、すでにディレクトリとして存在するエントリは setup の時点で throw し、起動を中断します。まだ存在しないパスを指すエントリは問題ありません — このディレクトリチェックは、そのパスが実際に存在するようになってから初めて実行されます。

  • まだ作られていないファイルも、親ディレクトリが存在するようになれば監視される。 watchFiles は、あなたの loader がのちに作成して読み返すファイルを、先んじて名指ししても構いません。zfb はファイル自体ではなくその親ディレクトリを監視対象として登録します(そうすることで削除・再作成も観測可能なままになります)。zfb が登録を試みた時点で親ディレクトリがまだ存在しない場合、その登録はスキップされ、以降の tick で自動的に再試行されます。そのため、親ディレクトリがディスク上に現れた時点で、zfb dev の再起動なしにそのファイルの監視が始まります。

  • 登録時の宣言であって、自動検出ではない。 一覧は setup が返った時点で確定します。ディレクトリを列挙する loader が監視するのは起動時に存在したファイルだけで、その後に作られたファイルは監視対象の外です。監視対象に加えるには zfb dev の再起動が必要です。loader の実行中に呼べる addWatchFile() のようなものはありません。

  • 登録自体は依然として凍結されている。 リフレッシュが置き換えるのは loader の 結果 です。specifier を追加することも、削除することも、loader 関数を差し替えることもできません。仮想モジュールの集合は setup が登録したものがすべてです。

  • 再実行に失敗したときは直前の正常なソースを保持する — そしてターミナルにそう表示する。 再実行した loader が throw した場合(書き込み途中のファイル、保存中に読んでしまった不完全な JSON など)、zfb は直前にメモ化したソースをそのまま保持し、プラグイン名と specifier を名指しした警告を表示します。

    plugin "<name>" failed to reload virtual module "<specifier>": <error>
      serving the last-good output for now; zfb will retry on the next change to a registered plugin watch file

    リフレッシュは all-or-nothing です。1 回の変更で複数の loader が再実行され、そのうち 1 つでも失敗した場合、新しいソースはどれも反映されません。更新が中途半端に混ざった状態が配信されることはありません。次に正常にロードできた変更でメモが置き換わるため、ファイルを削除して作り直せば自力で復帰します。

  • dev 専用。 zfb build は各 loader をちょうど一度だけ実行し、再実行することはないため、watchFiles は出荷される成果物に影響しません。無条件に宣言して構いません。

  • extraWatchPaths とは別物。 extraWatchPaths は、プロジェクトルート外のファイルの変更を dev ウォッチャーに 気づかせ、リビルドを起こします。しかしリビルドだけでは、メモ化された loader のソースがそのまま再生されるだけです。loader を無効化するのは watchFiles の役目であり、しかもパスの監視自体も watchFiles が引き受けます。ここで宣言したファイルに対応する extraWatchPaths のエントリは不要です。

衝突検出。 2 つのプラグインが同じ specifier を登録すると VirtualModuleConflict が発生し、ビルドを中断します。

injectRoute(pattern, entrypoint, opts?) — 合成ページルート

pages/<...>.tsx と同じページレンダリングパイプラインを通る URL パターンの下に ESM ページモジュールを登録します。entrypoint はソース TSX/TS でも、コンパイル済み ESM .js モジュールでも構いません。パッケージは公開済みの dist/ 内ルートを直接注入でき、利用側プロジェクトに元のルートソースをコピーする必要はありません。パターンは pages/ のファイル名文法(/blog/[slug]/api/dev/x/docs/[...rest])に従い、相対 entrypoint はプロジェクトルートを基準に解決されます。

injectRoute("/preset-page", "./preset/page.tsx");
injectRoute("/package-docs/[slug]", "./node_modules/@acme/docs/dist/routes/page.js");
injectRoute("/docs/[slug]", "./preset/doc.tsx", { prerender: true });
injectRoute("/account", "./preset/account.tsx", { prerender: false });

build モードでは、注入ルートはビルドごとの overlay pages root に materialize され、通常の scan -> bundle -> render パイプラインを通ります。opts.prerender はビルド形状を制御します。省略または true はルートを prerender し、false は SSR 形状のルートとしてマークします。output: "static" はそれを拒否します。

dev モードでは、静的および動的な注入ルートは zfb dev によってレンダリングされます。静的ルートは起動時に dev route universe へ seed され、動的ルートは最初のリクエスト時にレンダリングされます。opts.prerender は build 専用メタデータであり、dev では無視されます。

injectRoute("/", entrypoint) は build と dev のどちらでも有効です。プロジェクトに pages/index がなければサイトの root を提供します。同じ URL 形状を持つユーザー作成ルートは、/ に対する pages/index を含め、どちらのモードでも常に優先されます。

ルートがローカル開発だけを目的とする場合は、登録を自分でガードしてください。

setup({ command, injectRoute }) {
  if (command === "dev") {
    injectRoute("/dev/preview", "./scripts/dev-preview.tsx");
  }
}

衝突検出。 2 つのプラグインが同じ pattern を異なる entrypoint で登録すると InjectRouteConflict が発生し、build / dev の起動を中断します。

addClientEntry(entrypoint) — package-owned client scripts

プラグインまたは preset が所有するクライアント側 side-effect entry を登録します。

addClientEntry("./client/analytics.client.ts");

entrypoint*.client.{ts,tsx,js,jsx} ファイルを指す必要があります。client entry 名は .client を除いたファイル名 stem から導出され(analytics.client.ts -> analytics)、出力は通常の client-script 形状に従います。dev では安定 URL /assets/client/<name>.js、production ではハッシュ付き URL /assets/client/<name>-<hash>.js です。相対 entrypoint はプロジェクトルートから解決されます。

同じ名前を持つユーザー作成の client entry は、package-owned entry より優先されます。2 つのプラグインが同じ導出名を異なる entrypoint で登録すると ClientEntryConflict が発生します。*.client.* entry 規約に一致しないパスは InvalidClientEntry を発生させます。

injectRoute vs devMiddleware — 正しいフックを選ぶ

どちらも zfb dev 中に URL を追加できますが、狙う問題が異なります。

HookReturnsUse when
injectRoute(pattern, entrypoint, opts?)ページレンダラが評価して HTML にラスタライズする ESM ページモジュール(ソース TSX/TS またはコンパイル済み .jsディスク上の pages/ ツリーの外に存在する実在する JSX 形状のページが欲しいとき。package-owned build route も含みます。
devMiddleware(ctx)ctx.register(path, handler)リクエストごとに { status, headers, body } を返す JS 関数HTTP ハンドラが欲しいとき — JSON API、ホットリロードブリッジ、アップロードエンドポイントなど。ページパイプラインも JSX もなし。

応答がページでないときは devMiddleware が正解、ディスク上の pages/ ツリーの外に存在する合成ページモジュールが欲しいときは injectRoute が正解です。

同じ選択は previewMiddleware を通じて zfb preview にも当てはまります — これは devMiddleware のリクエストごとのハンドラを寸分たがわず継承したもので、dev ではなく preview の静的モードに紐づけられているだけです(後述の previewMiddleware(ctx) を参照)。injectRoute には preview 相当の仕組みはありません — preview は再レンダリングを一切行わないため、injectRoute で登録した合成ルートはそこでは無効です(前述の setup セクションを参照)。

閉じた面 — マークダウン拡張フックはなし

SetupContext が公開する登録メソッドは正確に 4 つだけです: addAliasaddVirtualModuleinjectRouteaddClientEntry。意図的に省かれているもの:

  • addRemarkPlugin / addRehypePlugin / addMarkdownVisitor はなし、

  • addModuleLoader / addModuleTransform はなし、

  • onConfigResolved / onModuleLoad はなし。

addVirtualModule の任意オプション watchFiles は、これを 5 つにするものではありません。すでに存在するメソッドの登録 オプション であり、得られるものも意図的に狭く抑えられています。loader が読むファイルを宣言すれば、その loader が再実行される — それだけです。汎用のホットアップデート面(Vite の handleHotUpdate / invalidate や、loader の実行中に呼べる addWatchFile() など)は、上の一覧にある他のものとまったく同じ理由で、省かれる側に属します。

マークダウンの拡張性は zfb のツリー 内部 に in-tree な Rust ビジター(TOC、外部リンク、CJK 処理など)として存在します。将来のマークダウン機能は JS プラグインポイントとして公開されるのではなく、エンジンに追加されます。これにより v1 のコントラクトは狭く保たれ、ビルドパイプラインは監査可能になります。

preBuild(ctx)postBuild(ctx)

  • preBuildsetup の後、バンドラ/レンダラ/CSS/islands の作業の前に実行されます。下流のステージが見るファイルを生成するのに使います。

  • postBuilddist/ が完全に書き出された後(アダプタによるラッピングを含む)に実行されます。ディスク上に完全なツリーを必要とする仕上げのステップに使います。

どちらのフックも { projectRoot, outDir, config, options, logger } を受け取ります。postBuild はさらに ctx.routes — ビルドの完全なルートマニフェスト — を受け取ります。完全な形については ZfbBuildHookContext を参照してください。

ctx.routes — ルートマニフェスト(postBuild のみ)

postBuild プラグインは、ビルドが生成したすべての URL を記述する ctx.routes オブジェクトを受け取ります。このフィールドは preBuild 中は 存在しません(undefined — マニフェストはレンダリングが完了するまで利用できません。

interface ZfbRouteManifest {
  routes: ZfbRouteEntry[];
}

interface ZfbRouteEntry {
  url: string;         // emitted URL path, e.g. "/blog/hello/"
  output: string;      // path under outDir, e.g. "blog/hello/index.html"
  extension: string;   // file extension: "html", "xml", "rss", "txt", "json", …
  source: string;      // source page module, e.g. "pages/blog/[slug].tsx"
  prerender: boolean;  // true = SSG (written to disk under outDir);
                       // false = SSR (no on-disk artifact, served by the adapter)
  params?: Record<string, string | string[]>; // absent for static routes;
                       // dynamic params are strings, catchall params are string[]
}

ルートは、実行をまたいでバイト単位で安定した出力にするため url でソートされます。HTML 以外のルート(sitemap.xml.tsxfeed.rss.tsxllms.txt.tsx)は、実際の拡張子と出力パスで現れます。

マニフェストには SSG ルート(prerender: true)と SSR ルート(prerender: false)の 両方 が含まれます。SSR ルートはアダプタが提供する有効な実行時 URL ですが、outDir の下にディスク上のアーティファクトを持ちません。「ビルドがディスクに書き出した URL」を列挙するインデックス(sitemap.xml、search-index.json など)は、それらを表面化させないために r.prerender !== false でフィルタすべきです。

ディスク上アクセス — dist/__zfb/routes.json

同じマニフェストは、すべての zfb build の終わりに <outDir>/__zfb/routes.json にも書き出されます(#347)。ディスク上のファイルはメモリ内の ctx.routes の形を 1 対 1 で反映します — 同じフィールド、同じ url ソート順 — ので、pnpm build に組み込まれた任意のスクリプト(兄弟の sitemap ジェネレータ、OGP インデクサ、検索シャードビルダ)は、zfb プラグインを書かずにマニフェストを読めます。

{
  "routes": [
    { "url": "/", "output": "index.html", "extension": "html",
      "source": "pages/index.tsx", "prerender": true },
    { "url": "/blog/hello/", "output": "blog/hello/index.html",
      "extension": "html", "source": "pages/blog/[slug].tsx",
      "prerender": true, "params": { "slug": "hello" } }
  ]
}

プラグインの ctx.routes とディスク上の routes.json は、同じデータに対する 2 つのアクセス形状であって、2 つのコントラクトではありません。deploy 前に dist/ から出荷アセット以外をすべて剥ぎ取るプロジェクトでは、zfb.config.tsemitRoutesManifest: false を設定してオプトアウトできます。

実践例: postBuildsitemap.xml を生成する

ビルドが生成したすべての HTML ルートから sitemap.xml を書き出すプラグインです。フィルタは extension === "html".xml / .rss / .txt ルートをスキップ)と prerender !== false(ディスク上のアーティファクトを持たない SSR ルートをスキップ)を組み合わせています。siteUrlctx.options から取得します — この plugin の zfb.config.ts 内の options エントリからそのままコピーされる、プラグイン固有のブロックです。これにより、ソースを編集しなくても同じプラグインモジュールをプロジェクトをまたいで使い回せます。

// plugins/sitemap.ts
import { definePlugin } from "@takazudo/zfb/plugins";
import { writeFileSync } from "node:fs";
import { join } from "node:path";

export default definePlugin({
  name: "sitemap",
  postBuild({ outDir, routes, options }) {
    if (!routes) return; // guard: absent on preBuild
    const siteUrl = options.siteUrl as string;
    const htmlRoutes = routes.routes.filter(
      (r) => r.extension === "html" && r.prerender !== false,
    );
    const xml = [
      '<?xml version="1.0" encoding="UTF-8"?>',
      '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
      ...htmlRoutes.map(
        (r) => `  <url><loc>${siteUrl}${r.url}</loc></url>`,
      ),
      "</urlset>",
    ].join("\n");
    writeFileSync(join(outDir, "sitemap.xml"), xml, "utf-8");
  },
});
// zfb.config.ts
import { defineConfig } from "@takazudo/zfb/config";

export default defineConfig({
  plugins: [
    { name: "./plugins/sitemap.ts", options: { siteUrl: "https://example.com" } },
  ],
});

options はこの PluginConfig エントリからそのままコピーされ、setuppreBuildpostBuild のすべてのフックに ctx.options として渡されます。型は付いていない(Record<string, unknown>)ので、上記のように読み出すフィールドはキャストするか検証してください。

このプラグインは dist/ が完全に書き出された後に実行されます。生成された HTML ページの隣に作るファイルは、本番では静的アセットとして提供されます。

devMiddleware(ctx)

v1 から変更ありません。ctx.register(path, handler) で 1 つ以上の HTTP ハンドラを登録します。ハンドラは { status, headers, body } を返します(または undefined を返すと zfb 組み込みの dev ルートにフォールスルーします)。

形については ZfbDevMiddlewareContext を参照してください。

リクエストとレスポンスの契約

ctx.register(path, handler) は、URL パスの プレフィックス ごとに 1 つのハンドラをインストールします。/doc-history への登録は /doc-history/doc-history/foo にマッチしますが、/doc-historyx にはマッチしません — これは単純な文字列プレフィックスではなく、境界を意識したプレフィックスマッチです。同じ pathregister を 2 回呼び出すと、以前登録したハンドラを 上書き します。同じパスに 2 つのハンドラを積み重ねる方法はありません。

ハンドラはリクエストを受け取ります。

interface ZfbDevMiddlewareRequest {
  method: string;
  url: string;
  headers: Record<string, string>; // lower-cased header names -> first value
  body?: string;                    // absent for GET/HEAD; UTF-8 only (no binary in v1)
}

そして(undefined を返すと、page cache や /__zfb/livereload.js など zfb 組み込みの dev ルートにフォールスルーします)レスポンスを返します。

interface ZfbDevMiddlewareResponse {
  status: number;
  headers?: Record<string, string>;
  body?: string;
  bodyEncoding?: "utf8" | "base64"; // set "base64" to return binary content
}

body はデフォルトで UTF-8 文字列です。バイナリペイロードを返すには bodyEncoding: "base64" を設定して base64 エンコードしてください。この契約は previewMiddleware が使うものと同じです — 次で説明します。

previewMiddleware(ctx)

devMiddlewarezfb preview(静的モード)版です(#1542、epic #1541 Preview Parity)。形も、ctx.register(path, handler) の呼び出しも、リクエストとレスポンスの契約も同じですが、モードごとに個別のオプトイン です。devMiddleware しか定義していないプラグインは zfb preview の下ではリクエスト時ハンドラを一切持たず、その逆も同様です。両方のモードで使えるようにしたいときは、同じハンドラを両方のフックに登録してください。

// plugins/api-echo.ts
import { definePlugin } from "@takazudo/zfb/plugins";

function echo({ method, url }: { method: string; url: string }) {
  return { status: 200, body: `${method} ${url}` };
}

export default definePlugin({
  name: "api-echo",
  devMiddleware({ register }) {
    register("/api/echo", echo);
  },
  previewMiddleware({ register }) {
    register("/api/echo", echo);
  },
});

devMiddleware との違いは 2 点です。

  • preBuild がない。 setup"preview" の下でも実行されます(前述)が、preview は再ビルドを行わないため、それに先行すべき preBuild/postBuild のステップがありません。

  • 静的モード専用。 previewMiddleware は、zfb preview がプロジェクト自身の静的ルーター(adapter: "none"/省略)を配信するときに実行されます。アダプタモードでは wrangler dev がサイト全体を直接配信します — zfb はそこでは自身のルーターを一切起動しないため、previewMiddleware の登録は単に実行されません。プロジェクトにプラグインが設定されていてアダプタモードが有効な場合、zfb preview は起動時に一度だけ警告します。

previewMiddleware は、devMiddlewarezfb devpublic/_redirects より優先されるのとまったく同じように、同じ URL に対して public/_redirects より優先されます — Static Assets — _redirects を参照してください。

形については ZfbPreviewMiddlewareContext を参照してください。

実践例: virtual:metadata-db

メタデータインデックスを構築し、それを仮想モジュールとして公開するプラグインです。ページはこれにより、インデックスのフォーマットについて zfb が何も知らないまま import metadata from "virtual:metadata-db" できます。

// plugins/metadata-db.ts
import { definePlugin } from "@takazudo/zfb/plugins";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

export default definePlugin({
  name: "metadata-db",
  setup({ projectRoot, addVirtualModule }) {
    addVirtualModule("virtual:metadata-db", () => {
      const dir = join(projectRoot, "src/content/docs");
      const entries = readdirSync(dir, { recursive: true })
        .filter((p) => typeof p === "string" && p.endsWith(".mdx"))
        .map((relPath) => {
          const body = readFileSync(join(dir, relPath as string), "utf-8");
          // ... parse frontmatter, compute slug, etc.
          return { slug: relPath, title: "...", description: "..." };
        });
      return `export default ${JSON.stringify(entries)}`;
    });
  },
});
// zfb.config.ts
import { defineConfig } from "@takazudo/zfb/config";

export default defineConfig({
  plugins: [{ name: "./plugins/metadata-db.ts" }],
});
// pages/index.tsx
import metadata from "virtual:metadata-db";
export default function Home() {
  return (
    <ul>
      {metadata.map((m) => (
        <li key={m.slug}><a href={`/${m.slug}`}>{m.title}</a></li>
      ))}
    </ul>
  );
}

loader は zfb build の最初に一度実行されます。バンドラは結果をキャッシュし、virtual:metadata-db のすべての import が同じソースを見ます。次の zfb build では loader が再び実行されます — ビルド間のディスク上キャッシュはありません。

zfb dev でも同じメモ化が効くため、これらの .mdx ファイルを編集しても、virtual:metadata-db は起動時に構築したインデックスを export し続けます。対象ファイルを先に列挙して宣言すれば、インデックスが入力に追随するようになります。

setup({ projectRoot, addVirtualModule }) {
  const dir = join(projectRoot, "src/content/docs");
  const files = readdirSync(dir, { recursive: true })
    .filter((p) => typeof p === "string" && p.endsWith(".mdx"))
    .map((p) => join(dir, p as string));

  addVirtualModule(
    "virtual:metadata-db",
    () => {
      // ... same body as above, reading each entry of `files`
    },
    { watchFiles: files },
  );
}

これらのファイルを編集すれば loader が再実行されます。新しいファイルを追加した場合は再実行されません。files が列挙されたのは setup の実行中なので、その後に作られたページが監視対象に入るのは次に zfb dev を再起動したときです。

衝突検出のまとめ

setup の登録が衝突しているか無効な場合、zfb は以下のいずれかのエラーで build / dev を中断します。

  • AliasConflict — 同じ from、異なる to

  • VirtualModuleConflict — 同じ specifier、異なるプラグイン。

  • InjectRouteConflict — 同じ URL パターン、異なるプラグイン。

  • ClientEntryConflict — 同じ導出 client-entry 名、異なる entrypoint。

  • InvalidClientEntryaddClientEntry のパスが有効な *.client.{ts,tsx,js,jsx} entry ではない。

必要ないとき — レシピを書く

ビルド時に動くものすべてがプラグインを必要とするわけではありません。タスクが setup レベルの機能を必要としない(仮想モジュールも、エイリアスも、注入ルートも、client entry も不要)、または dev ミドルウェアを必要としないなら、scripts/ に置いて pnpm build に組み込む素の Node.js スクリプトのほうがシンプルで、単独でテストしやすく、zfb 内部への結合も小さくなります。より広い原則については 設計哲学エンジンとフレームワーク のページが説明します。

プラグインを必要としない一般的な候補:

  • Sitemap 生成。 postBuildctx.routes を与えますが、同じ dist/ ツリーを単独のスクリプトから読むこともできます。モジュールグラフへのアクセスは不要です。pnpm build に組み込んでください。

  • OGP 画像の生成。 ページメタデータから open-graph 画像をレンダリングするのは、純粋なデータ入力/画像出力の変換です。ビルド済みの HTML や JSON マニフェストを読んで canvas/puppeteer/satori パイプラインを呼ぶ単独スクリプトはプラグインフックを必要としません。pnpm build に組み込んでください。

  • 検索インデックスの構築。 Pagefind や Lunr のようなツールは完成した dist/ ツリーをクロールします。zfb 内部へのアクセスは不要で、出力ディレクトリへのパスがあれば十分です。pnpm build に組み込んでください。

  • ビルド終了時のマニフェスト。 生成されたファイルから導く独自の JSON マニフェスト(アセット一覧、バージョンマップ、ルートカタログ)が必要なら、ビルド後に dist/ を読むスクリプトで自己完結します。pnpm build に組み込んでください。

関連項目

Revision History

作成更新