Read OSS

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_astswcを使って行います——これは高速で、常に実行されます。一方、型のチェックは任意です(--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種類のパーミッションシステムについて詳しく見ていきます。