Denoのコードベースを読み解く:アーキテクチャ、クレートマップ、コマンドの実行経路
前提知識
- ›Rustの基礎知識(トレイト、ジェネリクス、async/await)
- ›Cargoワークスペースの基本的な理解
Denoのコードベースを読み解く:アーキテクチャ、クレートマップ、コマンドの実行経路
DenoはRustで書かれたプロジェクトですが、単一のクレートではありません。5つのトップレベルディレクトリに75以上のクレートが分散したワークスペースです。それぞれのディレクトリには明確な役割があります。リポジトリを初めて開いたとき、ext/ や libs/ 以下に広がる大量のフォルダに圧倒されるかもしれません。この記事はそのための地図です。ディレクトリ構成、コンパイルを現実的なものに保つための依存レイヤー、そして deno run hello.ts がプロセスの起動からサブコマンドの実行に至るまでの流れを順に追っていきます。最後に、すべてをつなぎ合わせるサービスロケーターパターンも取り上げます。
5つのディレクトリ構成
Denoワークスペース内のすべてのクレートは、ルートの Cargo.toml に定義されています。75以上のメンバークレートは、次の5つのディレクトリに分類されます。
| ディレクトリ | 役割 | 主なクレート |
|---|---|---|
cli/ |
ユーザー向けバイナリ — フラグのパース、サブコマンド、各種ツール | deno(バイナリ本体)、cli/lib、cli/snapshot |
runtime/ |
全extensionを組み合わせてJavaScriptランタイムを構成 | deno_runtime、runtime/permissions、runtime/features |
ext/ |
JSへネイティブ機能を提供するextension群 | deno_fs、deno_net、deno_fetch、deno_node、deno_crypto |
libs/ |
再利用のために切り出された共有ライブラリ | deno_core、deno_ops、deno_resolver、deno_npm、serde_v8 |
tests/ |
統合テスト、ベンチマーク、Node.js互換テスト | tests/specs、tests/unit、tests/napi |
CLAUDE.md の開発者ガイドにはこう書かれています。cli/ クレートは「ユーザーが直接触れるインターフェース」、runtime/ はJavaScriptランタイムの組み立て担当、ext/ はJavaScriptにシステムアクセスを提供する、と。
graph TD
CLI["cli/<br/>User-facing binary<br/>Flag parsing, tools, LSP"]
RT["runtime/<br/>Runtime assembly<br/>Worker, permissions, bootstrap JS"]
EXT["ext/<br/>Extensions<br/>fs, net, fetch, node, crypto..."]
LIBS["libs/<br/>Shared libraries<br/>core, ops, resolver, npm..."]
TESTS["tests/<br/>specs, unit, integration<br/>Node.js compat, benchmarks"]
CLI --> RT
RT --> EXT
EXT --> LIBS
CLI --> LIBS
TESTS -.->|tests| CLI
style CLI fill:#4a9eff,color:#fff
style RT fill:#ff6b6b,color:#fff
style EXT fill:#ffd93d,color:#333
style LIBS fill:#6bcb77,color:#fff
style TESTS fill:#ccc,color:#333
ヒント:
libs/ディレクトリは比較的新しく、多くのクレートはエンベッダーやスタンドアロンバイナリによる再利用を可能にするため、cli/から切り出されたものです。複数のレイヤーを経由して再エクスポートされている型を見かけたら、その抽出作業がまだ進行中だと考えてよいでしょう。
クレートの依存レイヤー
レイヤー構造は厳格かつ意図的に設計されています。cli は runtime に依存し、runtime は ext/* クレートに依存し、ext/* クレートは libs/core に依存します。これは単なる整理術ではなく、コンパイルのファイアウォールとして機能します。cli/ に変更を加えても runtime/ や各extensionは再コンパイルされません。これほど大規模なプロジェクトでインクリメンタルビルドを現実的に行えているのは、この構造のおかげです。
graph BT
CORE["libs/core<br/>(deno_core)"]
OPS["libs/ops<br/>(deno_ops proc macros)"]
RESOLVER["libs/resolver<br/>(deno_resolver)"]
NPM["libs/npm*<br/>(npm_cache, npm_installer)"]
FS["ext/fs"]
NET["ext/net"]
NODE["ext/node"]
FETCH["ext/fetch"]
RUNTIME["runtime"]
CLI["cli"]
OPS --> CORE
FS --> CORE
NET --> CORE
FETCH --> CORE
NODE --> CORE
NODE --> FS
NODE --> NET
RUNTIME --> FS
RUNTIME --> NET
RUNTIME --> FETCH
RUNTIME --> NODE
CLI --> RUNTIME
CLI --> RESOLVER
CLI --> NPM
RESOLVER --> CORE
注目したいのは、ext/node が ext/fs や ext/net といった他のextensionに依存している点です。ファイルシステムやネットワークを扱うNode.js APIをポリフィルするために、これらが必要になるからです。この依存関係が生み出す微妙な順序制約については、extensionの登録を扱う第2回の記事で改めて取り上げます。
ワークスペース全体の依存関係は、ルート Cargo.toml の [workspace.dependencies] に集約されています。deno_ast、deno_graph、deno_lint などの重要な外部クレートはここでバージョンが固定されており、ワークスペース全体で一貫したバージョンが使われます。
プロセスの起動:main()からサブコマンドへ
プロセスのエントリーポイントは意図的にシンプルに保たれています。cli/main.rs は、バイナリをビルドせずにテストを実行できるようにするためだけに存在しています。
pub fn main() {
deno::main()
}
実際の起動シーケンスは cli/lib.rs にあります。main() 関数は、注意深い順序で初期化処理を進めていきます。
sequenceDiagram
participant Main as main()
participant Panic as Panic Hook
participant Platform as Platform Setup
participant TLS as TLS Init
participant Flags as Flag Parsing
participant V8 as V8 Init
participant Tokio as Tokio Runtime
participant Sub as Subcommand
Main->>Panic: setup_panic_hook()
Main->>Platform: init_logging, raise_fd_limit
Main->>Platform: Windows: disable_stdio_inheritance, enable_ansi
Main->>TLS: rustls default provider
Main->>Main: maybe_setup_permission_broker()
Main->>Tokio: create_and_run_current_thread_with_maybe_metrics
Tokio->>Flags: resolve_flags_and_init(args)
Flags-->>V8: init_v8(flags)
V8-->>Sub: run_subcommand(flags)
Sub-->>Main: exit code
いくつか注目すべき点があります。まず、パニックフックが最初にインストールされます。これはクラッシュ時に、Denoのバージョン、プラットフォーム情報、Issueを報告するためのリンクを含む、ユーザーフレンドリーなエラーレポートを表示するためのものです。V8のfatal errorハンドラーも上書きされており、C++の abort() を呼ぶ代わりにRustのpanicを発生させることで、パニックフックが確実に動作するようになっています。パーミッションブローカー(実験的なIPCベースのパーミッション委譲システム)のセットアップは、Tokioランタイムの起動前に行われます。DENO_PERMISSION_BROKER_PATH 環境変数の読み取りが必要なためです。
Tokioランタイムは create_and_run_current_thread_with_maybe_metrics で生成されます。これはシングルスレッドのランタイムです。V8のアイソレートは Send を実装していないため、Denoは意図的にcurrent-threadエグゼキューターを使用しています。また、V8 11.6で導入されたPKU(Protection Keys for Userspace)機能の制約により、V8自体はアイソレートを生成するすべてのスレッドの親スレッド上で初期化しなければなりません。
サブコマンドのディスパッチとDenoSubcommandEnum
DenoSubcommand enumはDenoが実行できるすべての操作を表しており、執筆時点で36個のvariantがあります。
pub enum DenoSubcommand {
Add(AddFlags),
Audit(AuditFlags),
Bench(BenchFlags),
Bundle(BundleFlags),
Cache(CacheFlags),
Check(CheckFlags),
Compile(CompileFlags),
// ... 30 more variants
Run(RunFlags),
Serve(ServeFlags),
Task(TaskFlags),
Test(TestFlags),
}
run_subcommand() 関数は巨大な match 式で、各variantをそれぞれのハンドラーにディスパッチします。すべてのアームは spawn_subcommand() でラップされています。
fn spawn_subcommand<F: Future<Output = T> + 'static, T: SubcommandOutput>(
f: F,
) -> JoinHandle<Result<i32, AnyError>> {
deno_core::unsync::spawn(
async move { f.map(|r| r.output()).await }.boxed_local(),
)
}
このパターンには実用的な理由があります。各サブコマンドのブランチが生成するfutureは非常に大きく、Flags 構造体全体と各種サービスオブジェクトをキャプチャします。これらを値としてコールスタックに積み上げると、Windowsのデバッグビルドでスタックオーバーフローを引き起こします。boxed_local() を使ってfutureをタスクとしてspawnすることで、futureはヒープに割り当てられ、スタックを小さく保てます。
flowchart LR
A[run_subcommand] --> B{match subcommand}
B -->|Run| C[spawn_subcommand]
B -->|Test| D[spawn_subcommand]
B -->|Fmt| E[spawn_subcommand]
B -->|Lint| F[spawn_subcommand]
B -->|...32 more| G[spawn_subcommand]
C --> H[tools::run::run_script]
D --> I[tools::test::run_tests]
E --> J[tools::fmt::format]
F --> K[tools::lint::lint]
Flags構造体と設定のパイプライン
Flags 構造体はマスター設定オブジェクトで、V8フラグからパーミッションの許可設定まで、約50のフィールドを持ちます。
pub struct Flags {
pub argv: Vec<String>,
pub subcommand: DenoSubcommand,
pub frozen_lockfile: Option<bool>,
pub type_check_mode: TypeCheckMode,
pub config_flag: ConfigFlag,
pub node_modules_dir: Option<NodeModulesDirMode>,
pub permissions: PermissionFlags,
pub v8_flags: Vec<String>,
pub code_cache_enabled: bool,
// ... ~40 more fields
}
CLIフラグは単独で機能するわけではなく、deno.json や package.json の設定とマージされます。CliFactory が生成する CliOptions 構造体がこのマージを担っており、設定ファイルの値よりも明示的なCLIフラグが優先されます。
特に興味深い設計上の決断として、run-to-taskフォールバックがあります。deno run some_name がモジュールを見つけられずに失敗したとき、should_fallback_on_run_error() 関数が、その引数をタスク名として再試行すべきかを判断します。この動作は runサブコマンドのハンドラー で確認できます。「モジュールが見つからない」エラーをキャッチして tools::task::execute_script にフォールバックする仕組みです。これはUXを重視した選択で、npmに慣れたユーザーが deno run test をタスクランナーのように使えることを意識したものです。
ヒント:
Flags内のPermissionFlags構造体は3段階の設計になっています。パーミッションの種類ごとにallow_*、deny_*、ignore_*が存在します。ignore_*は比較的新しく、特定のリソースに対するパーミッションプロンプトを抑制するために使います。
CliFactory:サービスロケーターパターン
CliFactory はすべてのピースが集結する場所です。約25個の Deferred<T> 型の遅延シングルトンを保持する CliFactoryServices 構造体を内包しています。
struct CliFactoryServices {
blob_store: Deferred<Arc<BlobStore>>,
caches: Deferred<Arc<Caches>>,
cli_options: Deferred<Arc<CliOptions>>,
code_cache: Deferred<Arc<CodeCache>>,
file_fetcher: Deferred<Arc<CliFileFetcher>>,
module_graph_builder: Deferred<Arc<ModuleGraphBuilder>>,
type_checker: Deferred<Arc<TypeChecker>>,
resolver_factory: Deferred<Arc<CliResolverFactory>>,
// ... ~17 more
}
Deferred<T> は OnceCell を薄くラップしたもので、get_or_try_init() と非同期バリアントを提供します。
pub struct Deferred<T>(once_cell::unsync::OnceCell<T>);
impl<T> Deferred<T> {
pub fn get_or_try_init(
&self,
create: impl FnOnce() -> Result<T, AnyError>,
) -> Result<&T, AnyError> {
self.0.get_or_try_init(create)
}
}
これは依存性注入ではなく、サービスロケーターパターンです。deno fmt が実行されるとき、必要なのはファイルフェッチャーとフォーマッターだけです。型チェッカーやnpmリゾルバー、モジュールグラフビルダーには一切触れません。それらの Deferred セルは未初期化のまま残ります。一方 deno run が実行されると、モジュールグラフビルダーが初期化され、それに連鎖してファイルフェッチャー、リゾルバーファクトリーなどが順次初期化されていきます。
classDiagram
class CliFactory {
+flags: Arc~Flags~
-services: CliFactoryServices
+from_flags(flags) CliFactory
+cli_options() Arc~CliOptions~
+file_fetcher() Arc~CliFileFetcher~
+module_graph_builder() Arc~ModuleGraphBuilder~
+type_checker() Arc~TypeChecker~
}
class CliFactoryServices {
blob_store: Deferred~Arc~BlobStore~~
caches: Deferred~Arc~Caches~~
cli_options: Deferred~Arc~CliOptions~~
file_fetcher: Deferred~Arc~CliFileFetcher~~
module_graph_builder: Deferred~Arc~ModuleGraphBuilder~~
type_checker: Deferred~Arc~TypeChecker~~
resolver_factory: Deferred~Arc~CliResolverFactory~~
}
class Deferred~T~ {
-cell: OnceCell~T~
+get_or_try_init(create) Result~T~
+get_or_try_init_async(create) Result~T~
}
CliFactory *-- CliFactoryServices
CliFactoryServices *-- "~25" Deferred
sync::OnceCell ではなく unsync::OnceCell を使っているのも意図的な選択です。CliFactory はTokioのcurrent-threadランタイム内の単一スレッドからのみアクセスされるため、同期のオーバーヘッドを完全に排除できます。
次回予告
全体の地図を描きました。5つのレイヤーに整理された75以上のクレート、パニックフックからV8、そしてTokioへと慎重な順序で進む起動シーケンス、36個のvariantを持つenumによるサブコマンドのディスパッチ、そして各コマンドが必要とするものだけをコストとして払う遅延サービスロケーター。次回は libs/core へと降りていきます。#[op2] マクロ、V8スナップショット、そして一連の仕組みをコンポーザブルにする Extension 抽象を通じて、extensionシステムがRustとJavaScriptをどのように橋渡しするかを探っていきましょう。