Read OSS

レジストリプロトコル:名前空間の解決・フェッチ・依存関係ツリー

上級

前提知識

  • 第1回:アーキテクチャ概要
  • グラフアルゴリズム(トポロジカルソート)の基本知識
  • HTTP APIおよびZodスキーマバリデーションの基礎知識

レジストリプロトコル:名前空間の解決・フェッチ・依存関係ツリー

第1回で確認したように、shadcn/ui はコンポーネントをHTTP経由で取得する静的JSONファイルとして配布しています。しかし「JSONファイルを取得する」というシンプルな操作の裏では、4段階からなる精巧なパイプラインが動いています。具体的には、名前空間構文のパース、プレースホルダーを展開したURL構築、キャッシュと認証ヘッダーを考慮したHTTPフェッチ、そしてトポロジカルソートを用いた再帰的な依存関係解決です。この記事では、shadcn add @acme/button という1回のコマンドが各レイヤーをどのように通過していくかを丁寧に追っていきます。

名前空間のパースとURL構築

@acme/button と入力すると、最初に行われるのが名前空間のパースです。parser.ts モジュールは、次の正規表現パターンを使って文字列を分割します。

/^(@[a-zA-Z0-9](?:[a-zA-Z0-9-_]*[a-zA-Z0-9])?)\/(.+)$/

この結果、{ registry: "@acme", item: "button" } が得られます。@ プレフィックスのない名前は、組み込みの @shadcn レジストリがデフォルトとして使われます。

パース結果は builder.ts に渡され、3つの処理が行われます。

  1. レジストリの検索BUILTIN_REGISTRIES + config.registries をマージした中からURLテンプレートを探す
  2. プレースホルダーの展開:URLテンプレート内の {name}{style} を実際の値に置き換える
  3. 環境変数の展開env.ts を通じて ${VAR_NAME} パターンを解決する
sequenceDiagram
    participant User
    participant Parser as parser.ts
    participant Builder as builder.ts
    participant Env as env.ts

    User->>Parser: "@acme/button"
    Parser->>Builder: {registry: "@acme", item: "button"}
    Builder->>Builder: Lookup "@acme" in registries
    Builder->>Builder: Replace {name} → "button"
    Builder->>Builder: Replace {style} → "radix-nova"
    Builder->>Env: Expand ${API_TOKEN}
    Env-->>Builder: resolved URL + headers
    Builder-->>User: {url, headers}

レジストリの設定は2つの形式に対応しています。"https://acme.com/r/{name}.json" のようなシンプルな文字列形式と、認証オプションを含むオブジェクト形式です。

{
  "url": "https://acme.com/r/{name}.json",
  "params": { "version": "latest" },
  "headers": { "Authorization": "Bearer ${ACME_TOKEN}" }
}

ヘッダーの値も同じ ${VAR} 展開が行われますが、ひとつ注意すべき挙動があります。shouldIncludeHeader は展開によって値が実際に変化したかどうかをチェックします。環境変数が設定されていない場合、空の認証トークンを送信するのではなく、そのヘッダーは静かに省略されます。これにより、レジストリへのアクセス権を持つ開発者とそうでない開発者が混在する環境でも、components.json をチームで共有できます。

ヒント: @shadcn レジストリのURLテンプレートは ${REGISTRY_URL}/styles/{style}/{name}.json です。{style} プレースホルダーがあることで、同じ button という名前でも設定したスタイルに応じた実装が返ります。これが、スタイルごとに異なる実装を提供する仕組みです。

フェッチャー:キャッシュ・プロキシ・エラー処理

URLが構築されると、次は fetcher.ts がHTTP通信を担います。このモジュールはインメモリのPromiseキャッシュを持っています。

const registryCache = new Map<string, Promise<any>>()

これは結果のキャッシュではなく、Promiseそのもののキャッシュです。同じURLへのリクエストが並行して発生した場合、2件目のリクエストは実行中の同じPromiseを受け取るため、HTTPリクエストは重複して発行されません。Promiseは await する前にキャッシュに格納される(115〜117行目)ため、重複排除のパターンとして正確に機能しています。

sequenceDiagram
    participant A as Request A
    participant B as Request B
    participant Cache as Promise Cache
    participant HTTP as HTTP

    A->>Cache: Has "button.json"?
    Cache-->>A: No
    A->>HTTP: fetch("button.json")
    A->>Cache: Store promise
    B->>Cache: Has "button.json"?
    Cache-->>B: Yes (pending promise)
    HTTP-->>A: Response
    Note over A,B: Both resolve with same data

プロキシサポートは https_proxy 環境変数を通じて自動的に有効になり、HttpsProxyAgent が使用されます。認証ヘッダーはレジストリのコンテキストモジュールから取得されます。

エラー処理も非常に丁寧に実装されています。フェッチャーは RFC 7807 のProblem Detailレスポンスをパースし(64〜87行目)、JSONエラーレスポンスから detail または message フィールドを取り出した上で、HTTPステータスコードを固有のエラー型にマッピングします。具体的には、401 → RegistryUnauthorizedError、404 → RegistryNotFoundError、410 → RegistryGoneError、403 → RegistryForbiddenError となります。

また fetchRegistryLocal を通じてローカルファイルの読み込みにも対応しており、ホームディレクトリパスのチルダ展開もサポートしています。これにより、レジストリのアイテムをディスク上のJSONファイルとして扱うローカル開発ワークフローが実現できます。

レジストリコンテキスト:ヘッダーの伝播

builderとfetcherの間に位置するのがコンテキストモジュールです。シンプルながら重要な接着剤の役割を果たしています。context.ts は、URL → ヘッダーのグローバルなマッピングを管理します。

interface RegistryContext {
  headers: Record<string, Record<string, string>>
}

builderが名前空間付きのアイテムを認証ヘッダー付きのURLに解決すると、そのヘッダーはコンテキストに保存されます。fetcherがHTTPリクエストを行う際には、URLに対応するヘッダーをコンテキストから取得します。この分離設計により、再帰的な依存関係リゾルバーがすべての関数呼び出しにヘッダーを引き回す必要がなくなります。認証が必要などのURLに対しても、グローバルに参照できるためです。

コンテキストは既存のヘッダーを置き換えるのではなく、新しいヘッダーをマージします。これは依存関係の解決中に重要な挙動です。たとえば @acme/button@acme/utils に依存している場合、両方のURLに対するヘッダーが共存できる必要があります。

再帰的な依存関係解決

resolver.ts は、レジストリシステムの中で最も複雑なモジュールです。resolveRegistryTree 関数はコンポーネント名のリストを受け取り、インストールに必要な依存関係を完全に解決したバンドルを返します。

flowchart TD
    A["resolveRegistryTree(['button'])"] --> B["fetchRegistryItems(['button'])"]
    B --> C{Has registryDependencies?}
    C -->|Yes| D["resolveDependenciesRecursively()"]
    C -->|No| E["Add to payload"]
    D --> F["Fetch each dependency"]
    F --> G{Dependency has deps?}
    G -->|Yes| D
    G -->|No| H["Add to items"]
    H --> I["Collect all items"]
    E --> I
    I --> J["topologicalSortRegistryItems()"]
    J --> K["Merge: files, deps, cssVars, css, fonts"]
    K --> L["Return resolved tree"]

resolveDependenciesRecursively は、3種類の依存関係を処理します。

  1. URLおよびローカルファイル:直接フェッチし、そのアイテム自身の依存関係を再帰的に解決する
  2. 名前空間付きアイテム@acme/utils):適切な認証ヘッダーを用いてbuilderを通じて解決する
  3. プレーンな名前button):後からインデックスベースで解決するためのレジストリ名として収集する

visited セットを使って循環依存によって無限ループが発生しないよう防いでいます。同じ依存関係が複数の依存チェーンに現れても、処理されるのは1回だけです。

Kahnのアルゴリズムによるトポロジカルソート

すべてのアイテムを収集した後、依存するアイテムが依存されるアイテムよりも先にインストールされるよう、順序を決定する必要があります。これは topologicalSortRegistryItems にてKahnのアルゴリズムとして実装されています。

flowchart TD
    A["Build adjacency list + in-degree map"] --> B["Find all nodes with in-degree 0"]
    B --> C["Add to queue"]
    C --> D{Queue empty?}
    D -->|No| E["Dequeue node → sorted list"]
    E --> F["Decrement in-degree of dependents"]
    F --> G{Any new in-degree 0?}
    G -->|Yes| C
    G -->|No| D
    D -->|Yes| H{sorted.length === items.length?}
    H -->|Yes| I["Return sorted"]
    H -->|No| J["Append remaining (circular deps)"]
    J --> I

この実装で注目すべきはアイデンティティの扱いです。各アイテムには、名前とソースから算出したハッシュが付与されます(computeItemHash)。同じコンポーネント名が異なるレジストリに存在する可能性があるためです。たとえば @acme/button@other/button はどちらも "button" という名前で解決されますが、実体としては別のアイテムです。

循環依存が検出されても(ソート済みアイテム数 < 全アイテム数の場合)、アルゴリズムはエラーをスローしません。残ったアイテムをリストの末尾に追加します(722〜740行目)。コンポーネントレジストリで循環依存が発生することは稀ですが、ゼロではありません。インストールを中断させるよりも、多少最適でない順序で処理を続けるほうが現実的という判断です。

ソート後、registry:theme アイテムはリストの先頭に移動されます。コンポーネントが参照するCSS変数を定義するテーマは、最初に処理される必要があるためです。

エラーシステム

レジストリモジュールは errors.ts に豊富なエラー階層を持っています。基底クラスである RegistryError は以下のフィールドを持ちます。

  • code:プログラムによるハンドリング用の14種類のEnum値のいずれか
  • statusCode:ネットワークエラー用のHTTPステータス
  • context:何が失敗したかを示す構造化されたメタデータ
  • suggestion:人間が読める形での修正方法の提案
  • timestamp:エラーが発生した日時

14種類の専用エラー型が全領域をカバーしています。

以下がその一覧です。

  • RegistryNotFoundErrorRegistryUnauthorizedErrorRegistryForbiddenErrorRegistryGoneError
  • RegistryFetchErrorRegistryNotConfiguredErrorRegistryLocalFileErrorRegistryParseError
  • RegistryMissingEnvironmentVariablesErrorRegistryInvalidNamespaceError
  • ConfigMissingErrorConfigParseErrorRegistriesIndexParseErrorInvalidConfigIconLibraryError

ヒント: カスタムレジストリを構築する際は、エラーレスポンスにRFC 7807のProblem Detail JSON形式を返すようにしましょう。shadcnのフェッチャーは detail フィールドを取り出してユーザーに表示するため、汎用的なHTTPステータスの説明よりもはるかにわかりやすいエラーメッセージを提供できます。

組み込みレジストリと定数

constants.ts はシステムの根幹となるファイルです。REGISTRY_URL はデフォルトで https://ui.shadcn.com/r ですが、REGISTRY_URL 環境変数で上書きできます。組み込みレジストリは次のように定義されています。

export const BUILTIN_REGISTRIES = {
  "@shadcn": `${REGISTRY_URL}/styles/{style}/{name}.json`,
}

このURLテンプレートは {name}{style} の両方のプレースホルダーを使っています。設定に style: "radix-nova" が指定されている場合、@shadcn/buttonhttps://ui.shadcn.com/r/styles/radix-nova/button.json に解決されます。

組み込みレジストリは上書きできません。getRawConfig 関数がこれを明示的にチェックし、components.json でユーザーが @shadcn を再定義しようとした場合はエラーをスローします。

次回予告

@acme/button が完全に解決された依存関係ツリーに変わるまでの流れを追いました。では、フェッチしたソースコードはその後どうなるのでしょうか?第3回では、ASTレベルの変換パイプラインを解説します。ts-morphによるimportの書き換え、PostCSSによるスタイルマップの解決、アイコンライブラリの差し替えなど、各コンポーネントをプロジェクト固有の設定に適合させる仕組みを掘り下げていきます。