URLから実行まで:DenoのモジュールロードとTypeScriptパイプライン
前提知識
- ›第1〜2回の記事
- ›JavaScriptのモジュールシステム(ESM、CJS、importマップ)
- ›TypeScriptコンパイルの基本的な理解
URLから実行まで:DenoのモジュールロードとTypeScriptパイプライン
第1回・第2回では、CLIがサブコマンドをどのようにディスパッチするか、そしてdeno_coreがextensionとopsを通じてRustとJavaScriptを橋渡しする仕組みを見てきました。しかし、deno run https://example.com/mod.tsを実行したとき、裏側では驚くべきことが起きています。DenoはインターネットからTypeScriptファイルを取得し、そのimport(URLであれnpmパッケージであれJSRモジュールであれ)を解決し、すべてをトランスパイルして実行します。package.jsonもビルドステップも不要です。この記事では、そのパイプライン全体を解剖します。多様なspecifierの種類、階層化されたリゾルバースタック、モジュールグラフの事前解析、そして現在JavaScriptベースのtscからGoベースのtsgoへ移行中のTypeScript型チェックシステムまで、順を追って見ていきましょう。
モジュールspecifierの多様性
Denoは、他のどのJavaScriptランタイムよりも多様なモジュールspecifierを扱います。Node.jsが主にベアspecifierと相対パスを対象とするのに対し、Denoは以下をすべて解決しなければなりません。
| specifierの種類 | 例 | 解決方法 |
|---|---|---|
| File URL | file:///home/user/mod.ts |
ファイルシステムへの直接アクセス |
| HTTPS URL | https://deno.land/std/path/mod.ts |
HTTPフェッチ + キャッシュ |
| importマップエントリ | std/path → マップされたURL |
importマップ解決 |
| JSR specifier | jsr:@std/path@1.0.0 |
JSRレジストリ解決 |
| npm specifier | npm:express@4 |
npmレジストリ + node_modules |
| ベアspecifier | lodash |
package.json、importマップ、またはエラー |
| Data URL | data:text/javascript,... |
インラインパース |
| Node組み込みモジュール | node:fs |
ext/nodeポリフィル |
graph TD
SPEC[Module Specifier] --> TYPE{Specifier Type?}
TYPE -->|file://| FS[Read from filesystem]
TYPE -->|https://| HTTP[Fetch + HTTP cache]
TYPE -->|jsr:| JSR[JSR registry lookup]
TYPE -->|npm:| NPM[npm resolution]
TYPE -->|node:| NODE[ext/node polyfill]
TYPE -->|import map| IMAP[Import map transform]
TYPE -->|bare| BARE{Has package.json<br/>or import map?}
BARE -->|Yes| IMAP
BARE -->|No| ERR[Error: Module not found]
IMAP --> TYPE
JSR --> HTTP
libs/resolver/lib.rsクレートは、解決ロジックを独立したライブラリとして切り出したものです。Node.js互換の解決処理にはnode_resolverを、package.jsonのパースにはdeno_package_jsonをそれぞれ利用し、その上に独自の解決戦略を重ねています。
リゾルバースタック
Denoのモジュール解決は単一の関数ではなく、それぞれ異なる役割を担うリゾルバーのスタックで構成されています。CLIはこれらをcli/resolver.rsの型エイリアスで組み合わせています。
pub type CliResolver = deno_resolver::graph::DenoResolver<
DenoInNpmPackageChecker,
DenoIsBuiltInNodeModuleChecker,
CliNpmResolver,
CliSys,
>;
DenoResolverは4つの型パラメーターでジェネリック化されており、CLI・スタンドアロンバイナリ・テストといった場面ごとに異なる実装を差し込めます。解決処理は以下のような層構造で進みます。
flowchart TD
INPUT["import 'foo/bar'"]
IM["1. Import Map Resolution<br/>Maps bare specifiers to URLs"]
JSR["2. JSR Resolution<br/>jsr: → registry URL"]
NPM["3. npm Resolution<br/>npm: → node_modules path"]
CJS["4. CJS/ESM Detection<br/>package.json type field"]
SLOPPY["5. Sloppy Imports<br/>Try .ts, .js, /index.ts"]
NODE_RES["6. Node Resolution<br/>node_modules traversal"]
FINAL["Resolved URL"]
INPUT --> IM
IM --> JSR
JSR --> NPM
NPM --> CJS
CJS --> SLOPPY
SLOPPY --> NODE_RES
NODE_RES --> FINAL
「Sloppy imports」は、開発者の利便性のためにDenoが設けた妥協策です。有効にすると、.tsや.js、/index.tsなどを末尾に付加して解決を試み、バンドラーが慣習的に提供してきた解決動作を再現します。sloppy解決が発動した場合はリゾルバーが警告を出力し、明示的なimportを促します。
ヒント:
CliCjsTracker型は、あるモジュールをCJSとESMのどちらとして扱うべきかを追跡します。これはnpm互換性において欠かせない仕組みです。多くのnpmパッケージは内部でrequire()を使っており、Denoは実行時ではなくモジュールロード時にこれを検出して適切に処理する必要があります。
CliModuleLoaderFactoryとModuleLoaderトレイト
cli/module_loader.rsは、V8がモジュールの解決とロードを必要とするときに呼び出すトレイト、deno_core::ModuleLoaderを実装しています。このファイルが約1,500行にもなるのは、モジュールロードがほぼすべてのサブシステム——ファイル取得、トランスパイル、npm解決、コードキャッシュ、グラフ解析——に関わるためです。
単一モジュールのロードフローは次のとおりです。
sequenceDiagram
participant V8 as V8 Engine
participant ML as ModuleLoader
participant Graph as Module Graph
participant Fetch as File Fetcher
participant Trans as Transpiler
participant Cache as Code Cache
V8->>ML: resolve(specifier, referrer)
ML->>Graph: Check prepared graph
Graph-->>ML: Resolved specifier
V8->>ML: load(specifier)
ML->>Graph: Lookup in graph
alt In graph (pre-loaded)
Graph-->>ML: Source + media type
else Not in graph
ML->>Fetch: Fetch module
Fetch-->>ML: Raw source
end
ML->>Trans: Transpile if TypeScript
Trans-->>ML: JavaScript source
ML->>Cache: Check V8 code cache
Cache-->>ML: Cached bytecode (if any)
ML-->>V8: ModuleSource { code, code_cache }
ModuleLoaderの実装は、モジュールグラフで事前解析済みのモジュール(deno runの通常パス)と、import()で動的にロードされるモジュールとを区別します。事前解析済みのモジュールはすでに取得・トランスパイル済みですが、動的importは新たなネットワークリクエストを引き起こす可能性があります。
コードキャッシュの統合も興味深い点です。Denoはソースコードのハッシュをキーとして、V8がコンパイルしたバイトコードをモジュールのソースコードと並べて保存します。次回以降の実行時にはキャッシュされたバイトコードが読み込まれ、V8はパースとコンパイルのフェーズをまるごとスキップできます。これはHTTPキャッシュとは別物で、V8の内部表現を対象としたキャッシュです。
モジュールグラフ:依存関係の事前解析
コードを一切実行する前に、Denoはdeno_graphを使って完全な依存グラフを構築します。この処理を統括するのがcli/graph_util.rsモジュールです。
flowchart LR
ROOT["Root module<br/>hello.ts"] --> BUILD["ModuleGraphBuilder<br/>.build()"]
BUILD --> FETCH["Parallel fetch<br/>all dependencies"]
FETCH --> PARSE["Parse imports<br/>(deno_ast)"]
PARSE --> RESOLVE["Resolve specifiers<br/>(deno_resolver)"]
RESOLVE --> RECURSE{More deps?}
RECURSE -->|Yes| FETCH
RECURSE -->|No| GRAPH["Complete<br/>ModuleGraph"]
GRAPH --> CHECK["Type check<br/>(optional)"]
GRAPH --> PREP["ModuleLoadPreparer<br/>makes graph available<br/>to ModuleLoader"]
CHECK --> EXEC["Execute"]
PREP --> EXEC
deno_graphクレートのModuleGraphは、importを再帰的に辿ることで構築されます。各モジュールは(場合によってはHTTP経由で)取得され、import/export文を抽出するためにパースされ、その依存モジュールが解決されてキューに追加されます。この処理は並列に行われ、複数のHTTPリクエストが同時に飛び交いながら、CPUバウンドなパース処理と交互に進みます。
モジュールグラフはさまざまな用途に使われます。
- 並列フェッチ: 実行開始前にすべてのモジュールを取得し、ウォーターフォール的な遅延を防ぐ
- 型チェック: 型チェッカーがクロスモジュールの型参照を解決するために完全なグラフが必要
- エラー報告: 不足モジュール、循環依存、解決エラーをコード実行前に検出して報告する
- ロックファイル検証: モジュールの整合性ハッシュをロックファイルと照合する
ModuleLoadPreparerはグラフとModuleLoaderを橋渡しする役割を担っています。グラフの構築と(任意の)型チェックが完了したのち、V8のモジュール評価時にModuleLoaderがアクセスできる場所にグラフを格納します。
TypeScriptコンパイル:tsc対tsgo
Denoの型チェックシステムは、現在アーキテクチャの移行期にあります。cli/type_checker.rsモジュールが両方のバックエンドを統括しています。
レガシーシステム(cli/tsc/mod.rs)は、JavaScriptベースのTypeScriptコンパイラを専用のV8アイソレート内で実行します。DenoはopsによるカスタムCompilerHost実装を提供しており、TypeScriptコンパイラはこれを通じてモジュールの解決、ファイルの読み込み、出力の書き込みを行います。これは本質的に、RustプログラムにJavaScriptランタイムが埋め込まれ、さらにその中にTypeScriptコンパイラが組み込まれた構造です。
新システム(cli/tsc/go.rs)は、RPCでDenoと通信するGoベースのTypeScript型チェッカーtsgoを利用します。45行目のコメントには、現実的な回避策の背景が率直に述べられています。
// the way tsgo currently works, it really wants an actual tsconfig.json file.
// it also doesn't let you just pass in root file names. instead of making more
// changes in tsgo, work around both by making a fake tsconfig.json file with
// the "files" field set to the root file names.
GoクライアントはメモリT上に擬似的なtsconfig.jsonを生成し、それを本物のファイルであるかのようにtsgoへ渡します。そしてSyncRpcChannelを介したRPCチャネル経由で、GoからRustへのモジュール解決コールバックを処理します。
sequenceDiagram
participant TC as TypeChecker
participant Legacy as tsc (JS in V8)
participant Go as tsgo (Go via RPC)
TC->>TC: Check DENO_USE_TSGO env var
alt Legacy mode
TC->>Legacy: Create V8 isolate
Legacy->>Legacy: Load TypeScript compiler
Legacy->>TC: Request modules (via ops)
TC-->>Legacy: Module sources
Legacy-->>TC: Diagnostics
else tsgo mode
TC->>Go: Spawn tsgo process
Go->>TC: ResolveModuleName callback
TC-->>Go: Resolved specifier
Go->>TC: ReadFile callback
TC-->>Go: File contents
Go-->>TC: Diagnostics
end
重要な点として、型チェックとトランスパイルは完全に別の処理です。TypeScriptのトランスパイル(型を除去してJavaScriptへ変換すること)はモジュールロード時にdeno_astがswcを使って行います——これは高速で、常に実行されます。一方、型のチェックは任意です(--no-checkでスキップ可能)。こちらはモジュールグラフ全体にわたって型の正しさを検証する、コストの高い処理です。
ヒント:
TypeCheckMode列挙型には3つのバリアントがあります。None(チェックをスキップ)、Local(ローカルファイルのみチェックしnode_modulesはスキップ)、All(依存関係を含めすべてをチェック)です。deno checkのデフォルトはLocalで、依存関係もチェックするには--allを使います。
ファイルの取得とキャッシュ
cli/file_fetcher.rsモジュールは、ローカルファイル・HTTP URL・Data URLなど、あらゆるソースからのモジュール取得を担います。リモートモジュールに対しては、HTTPキャッシュのためにdeno_cache_dirと連携します。
flowchart TD
REQ["Fetch request<br/>https://deno.land/std/path/mod.ts"]
LOCAL{Local file?}
REQ --> LOCAL
LOCAL -->|Yes| READ[Read from disk]
LOCAL -->|No| CACHE{In HTTP cache?}
CACHE -->|Yes, fresh| HIT[Return cached]
CACHE -->|Yes, stale| REVALIDATE[Conditional GET<br/>If-None-Match / If-Modified-Since]
CACHE -->|No| FETCH[HTTP GET]
REVALIDATE -->|304| HIT
REVALIDATE -->|200| STORE[Store in cache]
FETCH --> STORE
STORE --> DECODE[Detect charset<br/>Decode to UTF-8]
READ --> DECODE
HIT --> DECODE
DECODE --> FILE["TextDecodedFile<br/>{specifier, source, media_type}"]
TextDecodedFile構造体は統一された出力形式で、specifier・デコードされたソーステキスト・MediaType(TypeScript、JavaScript、JSX、JSONなど)を持ちます。MediaTypeはHTTPヘッダーまたはファイル拡張子から判定され、以後のトランスパイル処理を決定します——TypeScriptファイルはswcを経由し、JavaScriptファイルはそのまま通過します。
CacheSetting列挙型はキャッシュの動作を制御します。Useはデフォルトでキャッシュを使用し、なければ取得します。Onlyはオフラインモードで、キャッシュにない場合はエラーになります。ReloadAllはキャッシュを無視してすべて再取得し、ReloadSomeは選択的な再取得を行います。--cached-onlyフラグと--reloadフラグがそれぞれに対応します。
次回予告
specifier文字列から実行済みJavaScriptまでの旅路をたどってきました。階層化されたスタックによる解決、依存グラフへの並列フェッチ、JavaScriptベースまたはGoベースのTypeScriptコンパイラによる任意の型チェック、swcによるトランスパイル、そしてコードキャッシュを活用したV8での実行——これらすべてがひとつのパイプラインを形成しています。次回はMainWorker自体にフォーカスします。その生成方法、extensionモジュールからDenoグローバル名前空間を構築するブートストラップシーケンス、そしてopの境界でセキュリティを強制する8種類のパーミッションシステムについて詳しく見ていきます。