Read OSS

DenoのツールチェーンとNode.js互換性:NPM、LSP、そしてToolsディレクトリ

上級

前提知識

  • 記事1〜4
  • Node.js/npmエコシステム(package.json、node_modules、npmレジストリ)
  • LSPプロトコルの基本概念

DenoのツールチェーンとNode.js互換性:NPM、LSP、そしてToolsディレクトリ

これまでの4本の記事では、プロセスエントリからサブコマンドディスパッチ(記事1)、Rust-V8拡張ブリッジ(記事2)、モジュールロードとTypeScriptコンパイル(記事3)、Workerブートストラップとパーミッション(記事4)を追ってきました。この最終回では、これらのインフラの上でDenoが実際に何をするのかを見ていきます。具体的には、統合ツールチェーン、deno compileのスタンドアロンバイナリパイプライン、npm互換レイヤー、200以上のNode.js API polyfill、フルLSP実装、specテストフレームワークを取り上げます。

Toolsディレクトリ:組み込み開発者ツールチェーン

cli/tools/mod.rsには、23個のツールモジュールが宣言されています。

pub mod bench;
pub mod bundle;
pub mod check;
pub mod clean;
pub mod compile;
pub mod coverage;
pub mod deploy;
pub mod doc;
pub mod fmt;
pub mod info;
pub mod init;
pub mod installer;
pub mod jupyter;
pub mod lint;
pub mod pm;        // package management (add, remove, audit, outdated)
pub mod publish;
pub mod repl;
pub mod run;
pub mod serve;
pub mod task;
pub mod test;
pub mod upgrade;
pub mod x;

各ツールモジュールは、記事1で見たrun_subcommand()からディスパッチされ、Arc<Flags>とサブコマンド固有のフラグ構造体を受け取ります。ツール間の共通処理はCliFactoryを通じて共有されており、factory.cli_options()factory.module_graph_builder()factory.type_checker()などを必要に応じて呼び出す仕組みになっています。Deferred<T>により、各サービスの初期化は一度だけに抑えられます。

graph LR
    subgraph "Tools"
        FMT[fmt]
        LINT[lint]
        TEST[test]
        BENCH[bench]
        DOC[doc]
        COMPILE[compile]
        PUBLISH[publish]
        TASK[task]
        REPL[repl]
    end

    subgraph "Shared via CliFactory"
        FF[FileFetcher]
        MGB[ModuleGraphBuilder]
        TC[TypeChecker]
        RF[ResolverFactory]
    end

    FMT --> FF
    LINT --> FF
    TEST --> MGB
    TEST --> TC
    BENCH --> MGB
    DOC --> MGB
    COMPILE --> MGB
    PUBLISH --> MGB
    PUBLISH --> TC
    TASK --> RF

ここで重要なのは、deno fmtはtype checkerに一切触れず、deno lintはmodule graph builderを必要とせず、deno taskはそれらのサービスをほとんど使わないという点です。Deferred<T>パターン(記事1参照)により、起動コストは各ツールが実際に使うサービスの分だけに抑えられます。

ヒント: ツールによって実装の規模は大きく異なります。cli/tools/fmt.rsは1ファイルで完結していますが、cli/tools/test/はテスト探索・ランナー・レポーター・カバレッジ統合のサブモジュールを持つ本格的なディレクトリ構成になっています。新しいツールを追加するときは、類似ツールの実装を参考にしましょう。

deno compile:スタンドアロンバイナリのパイプライン

deno compileコマンドは、実行先のマシンにDenoがインストールされていなくても動く、完全に自己完結した実行ファイルを生成します。cli/standalone/binary.rsのパイプラインは、いくつかのステージを経て処理されます。

flowchart TD
    SRC["Source module<br/>main.ts"]
    GRAPH["Build ModuleGraph<br/>(all dependencies)"]
    RESOLVE["Resolve npm packages<br/>+ node_modules"]
    VFS["Build VirtualFs<br/>Embed files in memory"]
    META["Serialize Metadata<br/>flags, import map, lockfile"]
    BASE["Fetch base Deno binary<br/>(for target platform)"]
    APPEND["Append eszip + VFS + metadata<br/>to binary"]
    MAGIC["Write MAGIC_BYTES trailer"]
    OUT["Standalone executable"]

    SRC --> GRAPH
    GRAPH --> RESOLVE
    RESOLVE --> VFS
    VFS --> META
    META --> BASE
    BASE --> APPEND
    APPEND --> MAGIC
    MAGIC --> OUT

cli/standalone/virtual_fs.rsモジュールが担うVirtualFsは、バイナリ内に埋め込まれたファイルシステムで、node_modules・ローカルソースファイル・その他のアセットを提供します。VfsBuilderはファイルを重複排除(同一コンテンツを1度だけ保存)し、output_vfs()関数は埋め込まれたファイルのサイズをツリー形式で表示します。

pub fn output_vfs(vfs: &BuiltVfs, executable_name: &str) {
  if !log::log_enabled!(log::Level::Info) {
    return;
  }
  let display_tree = vfs_as_display_tree(vfs, executable_name);
  log::info!("\n{}\n", deno_terminal::colors::bold("Embedded Files"));
  log::info!("{}", text.trim());
}

バイナリに書き込まれるMetadata構造体には、ランタイム環境を再構築するために必要な情報がすべて含まれています。CLIフラグ、ワークスペースリゾルバの設定、npm解決スナップショット、CJSエクスポート解析、そしてimport mapです。実行時、スタンドアロンバイナリはMAGIC_BYTESトレーラーからこれらを読み込み、埋め込まれたeszipと仮想ファイルシステムをバックエンドとするカスタムModuleLoaderを持つMainWorkerを再構築します。

NPM統合の詳細

Denoは2つのnpm解決モードをサポートしており、node_modules/ディレクトリが存在するかどうかで切り替わります。

Managedモード(デフォルト):Denoが$DENO_DIR配下に独自のnpmキャッシュを管理します。パッケージは解決・ダウンロードされ、グローバルにキャッシュされます。明示的に指定しない限り、node_modules/ディレクトリは作成されません。この実装はlibs/npm_installer/クレートにあります。

BYONMモード(Bring Your Own Node Modules):node_modules/ディレクトリが存在する場合(通常はnpm installpnpm installで作成されたもの)、Denoはそこから直接読み込みます。このモードは--node-modules-dirフラグを指定するか、既存のnode_modules/を検出したときに有効になります。

graph TD
    REQ["npm:express@4"]
    MODE{Resolution mode?}
    REQ --> MODE
    
    MODE -->|Managed| MGLOBAL["Global npm cache<br/>$DENO_DIR/npm/"]
    MGLOBAL --> RESOLVE_M["Resolve version<br/>from registry"]
    RESOLVE_M --> CACHE["Cache tarball<br/>+ extract"]
    CACHE --> LINK["Create virtual<br/>node_modules layout"]
    
    MODE -->|BYONM| LOCAL["Read node_modules/<br/>on disk"]
    LOCAL --> RESOLVE_B["Resolve from<br/>package.json"]
    RESOLVE_B --> FOUND["Use installed<br/>package"]

    LINK --> READY["Package ready"]
    FOUND --> READY

cli/npm.rsファイルには、CLIに特化したnpm型としてCliNpmInstallerFactoryCliNpmInstallerCliNpmCacheが定義されており、いずれもシステムトレイトCliSysでパラメタライズされています。ライフサイクルスクリプトエグゼキュータはpreinstallinstallpostinstallスクリプトを処理します。注目すべきはis_broken_default_install_script()関数で、Denoの環境では必ず失敗する既知の壊れたインストールスクリプトを検出してスキップする仕組みになっています。

deno adddeno removeコマンド(tools::pmにディスパッチ)はdeno.jsonまたはpackage.jsonを変更し、依存関係の宣言を更新してインストールを実行します。deno outdatedコマンドはすべての依存関係について新しいバージョンを確認します。これらのパッケージ管理コマンドは、libs/npm/libs/npm_cache/libs/npm_installer/クレートを通じてインフラを共有しています。

Node.js Polyfillレイヤー(ext/node/)

ext/node/lib.rs拡張は、Deno全体の中で最大の単一拡張です。Rust opsとJavaScript polyfillの両方を通じて、200以上のNode.js API shimを提供しています。

pub use node_resolver::DENO_SUPPORTED_BUILTIN_NODE_MODULES 
    as SUPPORTED_BUILTIN_NODE_MODULES;

この拡張は2つのレベルで動作します。

  1. JavaScript polyfillsext/node/polyfills/):pathurleventsbufferstreamcrypto(一部)・assertなど、Node.jsモジュールの純粋なJavaScript実装です。

  2. Rust opsext/node/ops/):パフォーマンスが重要な処理をRustで実装したものです。node:crypto操作(ext/node_crypto/に委譲)、node:child_process(Denoのサブプロセスシステムにマップ)、node:fsext/fsに委譲)、node:vm(カスタムV8コンテキスト管理)などが含まれます。

graph TD
    subgraph "Node.js Compatibility"
        REQUIRE["require('fs')"]
        IMPORT["import fs from 'node:fs'"]
        
        RESOLVER["Node Module Resolver<br/>libs/node_resolver/"]
        POLYFILL["JavaScript Polyfills<br/>ext/node/polyfills/"]
        RUST_OPS["Rust Ops<br/>ext/node/ops/"]
        DENO_EXT["Deno Extensions<br/>ext/fs, ext/net, ext/crypto"]
    end
    
    REQUIRE --> RESOLVER
    IMPORT --> RESOLVER
    RESOLVER --> POLYFILL
    POLYFILL --> RUST_OPS
    RUST_OPS --> DENO_EXT

NodeRequireLoaderトレイトの抽象化により、CLI・スタンドアロン・テストといった各コンテキストで異なるrequire()実装を持つことができます。is_maybe_cjs()メソッドは、モジュールをCJS(require()セマンティクス)として読み込むかESMとして読み込むかを判断します。この判断は、最も近いpackage.json"type"フィールド、ファイル拡張子、その他のヒューリスティクスに基づいて行われます。

ヒント: ext/node/ops/vm.rsにあるVM_CONTEXT_INDEX定数とcreate_v8_context()init_global_template()関数は、Node.jsのvm.createContext()を実装しています。独自のグローバルオブジェクトを持つ別のV8コンテキストを生成するもので、jsdomのようにこのAPIに依存するnpmパッケージを動かすために必要な仕組みです。

LSPアーキテクチャ

cli/lsp/mod.rsモジュールには、tower-lsp上に構築されたフルLanguage Server Protocolサーバーを実装する34のサブモジュールが含まれています。

mod analysis;
mod cache;
mod capabilities;
mod client;
mod code_lens;
mod completions;
mod config;
mod diagnostics;
mod documents;
mod jsr;
mod lint;
mod npm;
mod performance;
mod refactor;
mod registries;
mod resolver;
mod semantic_tokens;
mod testing;
mod tsc;
mod tsgo;
// ... more

サーバーはlsp::start()によって起動され、tower-lspサービスを生成します。

pub async fn start() -> Result<(), AnyError> {
  let stdin = tokio::io::stdin();
  let stdout = tokio::io::stdout();
  let builder = LspService::build(|client| {
    LanguageServer::new(client::Client::from_tower(client))
  });
  // ...
}

LanguageServer構造体は、ドキュメントの状態・診断・TypeScriptサービスとの統合・クライアントとの通信を管理します。CLIの型チェックとの主要な設計上の違いは以下のとおりです。

  • ドキュメント中心:LSPはエントリーポイントだけでなく、開いているドキュメントとその依存関係を追跡します
  • インクリメンタル:1ファイルの変更は、グラフ全体の再構築ではなく、対象を絞った再解析をトリガーします
  • デュアルTypeScriptlsp/tsc.rs(従来型)とlsp/tsgo.rs(新しいGoベース)の両方が型情報を提供します
  • 診断パイプライン:TypeScript・deno_lint・インポート解決など複数のソースからの診断がマージ・重複排除されます
flowchart LR
    subgraph "LSP Server"
        DOC["Document Manager<br/>Open files + dependencies"]
        DIAG["Diagnostics Pipeline<br/>TS + lint + resolution"]
        TSC["TypeScript Service<br/>Completions, hover, refs"]
        CONFIG["Config Manager<br/>deno.json, workspace settings"]
    end

    EDITOR["Editor / IDE"] <-->|LSP Protocol| DOC
    DOC --> DIAG
    DOC --> TSC
    CONFIG --> DIAG
    CONFIG --> TSC
    DIAG -->|publishDiagnostics| EDITOR
    TSC -->|completion, hover| EDITOR

テストインフラ:Specテストと統合テスト

Denoの主要な統合テスト形式は「specテスト」で、CLAUDE.mdにドキュメントがあります。各テストは1つのディレクトリで構成されており、CLIコマンドと期待される出力を記述する__test__.jsoncファイルを含んでいます。

{
  "tests": {
    "basic_case": {
      "args": "run main.ts",
      "output": "expected.out"
    },
    "with_flag": {
      "steps": [{
        "args": "run --allow-net main.ts",
        "output": "[WILDCARD]success[WILDCARD]"
      }]
    }
  }
}

出力マッチングにはワイルドカード構文が使えます。

パターン 意味
[WILDCARD] 0文字以上にマッチ(.*相当)、改行を跨ぐ
[WILDLINE] 0文字以上にマッチ、行末で止まる
[WILDCHAR] ちょうど1文字にマッチ
[WILDCHARS(5)] ちょうど5文字にマッチ
[UNORDERED_START]...[UNORDERED_END] 任意の順序で行にマッチ
[# comment] 行コメント、無視される

このワイルドカードシステムは、マシンによって異なるファイルパス・タイムスタンプ・その他の非決定的な出力を含むランタイムのテストに欠かせません。[UNORDERED_START/END]ブロックは、並行出力における競合状態に対応するためのものです。

記事1で取り上げたrun-to-taskフォールバックは、UX設計としても注目に値します。should_fallback_on_run_error()関数は、モジュールが見つからなかったときにdeno run <name>deno task <name>として再試行するかどうかを判定します。

fn should_fallback_on_run_error(script_err: &str) -> bool {
  if script_err.starts_with(MODULE_NOT_FOUND)
    || script_err.starts_with(UNSUPPORTED_SCHEME)
  {
    return true;
  }
  let re = lazy_regex::regex!(
    r"Import 'file:///.+?' failed\.\n\s+0: .+ \(os error \d+\)"
  );
  re.is_match(script_err)
}

このフォールバック動作と、モジュールもタスクも存在しない場合のエラーメッセージは、specテストによって検証されています。

シリーズのまとめ

5本の記事を通じて、Denoの完全なアーキテクチャを追ってきました。

  1. アーキテクチャ:5つのディレクトリに75クレート、階層化された依存グラフ、遅延サービス初期化を持つCliFactory
  2. V8ブリッジ:ExtensionsがopsとJSをバンドルし、#[op2]がV8バインディングを生成し、スナップショットがビルド時にJSヒープをシリアライズする
  3. モジュールロード:階層化されたスタックによるSpecifier解決、deno_graphによる事前解析、tscとtsgoによるデュアルTypeScript型チェック
  4. WorkerMainWorkerの生成、Rust-V8-JS境界を越えたブートストラップ、ブローカーサポートを持つ8種類のパーミッションシステム
  5. ツールチェーン:23の統合ツール、deno compileの仮想ファイルシステム、npmのManaged/BYONMモード、200以上のNode.js polyfill、フルLSP

Denoのコードベースは大規模ですが、構造はよく整理されていますlibs/ext/runtime/cli/間の階層化は、単なる整理のためだけでなく、コンパイルの防火壁と抽象化の境界としても機能しています。ExtensionsはComposableな単位であり、opsはセキュリティ境界であり、Deferred<T>パターンは使う分だけコストを払う設計を実現しています。バグ修正の貢献でも、新しいopの追加でも、あるいはdeno_coreを使った独自ランタイムの構築でも、この5つの層がリファレンスアーキテクチャとなるはずです。