Read OSS

V8ブリッジ:DenoのExtensionシステムがRustとJavaScriptをつなぐ仕組み

上級

前提知識

  • 第1回:アーキテクチャとCrateマップ
  • V8の基本概念(isolate、context、handle、snapshot)
  • Rustのprocマクロの基礎知識

V8ブリッジ:DenoのExtensionシステムがRustとJavaScriptをつなぐ仕組み

第1回では、cli/ がコマンドをディスパッチする流れと、CliFactory がサービスを遅延初期化する仕組みを見ました。しかし、最も根本的な問いには触れませんでした。「RustのコードはどうやってJavaScriptから呼び出せるようになるのか?」という問いです。その答えが deno_core です。5,000行を超えるこの基盤 crate は、V8をラップしつつ、Rust関数をJavaScriptランタイムに接続するExtensionシステムを提供しています。本記事では、Extension 構造体から #[op2] procマクロ、ファイルシステムの具体的な実装例、V8スナップショットによる最適化、そして起動時間を最大限に削り出す UnconfiguredRuntime パターンまで、このブリッジの全貌を追います。

deno_core:エンジンルーム

libs/core/lib.rs は再エクスポートのカタログとして機能しており、この crate が公開する主要な抽象化を把握するのに最適な出発点です。

  • JsRuntime — V8 isolateをイベントループ・モジュールローダー・opディスパッチと合わせてラップする
  • Extension — opとJavaScriptソースファイルをまとめて登録可能な単位にバンドルする
  • OpState — opが読み取るスレッドローカルな状態(パーミッション、ファイルシステムなど)を保持するバッグ
  • ModuleLoader trait — ESモジュールの解決・ロード方法を定義する
classDiagram
    class JsRuntime {
        +execute_script()
        +load_main_es_module()
        +run_event_loop()
        +op_state() OpState
        +lazy_init_extensions()
    }
    class Extension {
        +name: &str
        +deps: &[&str]
        +ops: Cow~[OpDecl]~
        +esm_files: Cow~[ExtensionFileSource]~
        +lazy_loaded_esm_files
        +enabled: bool
    }
    class OpState {
        +put~T~(value)
        +borrow~T~() &T
        +borrow_mut~T~() &mut T
    }
    class OpDecl {
        +name: &str
        +is_async: bool
        +slow_fn: OpFnRef
        +fast_fn: Option~CFunction~
    }
    JsRuntime --> Extension : registers
    JsRuntime --> OpState : owns
    Extension --> OpDecl : contains

runtime/mod.rs はランタイムを責務ごとのサブモジュールに分割しています。jsruntime(メインの JsRuntime 構造体)、jsrealm(V8 contextの管理)、snapshot(スナップショットの作成・ロード)、bindings(V8関数コールバックの紐付け)、op_driver(非同期opのスケジューリング)がそれぞれの役割を担います。

ヒント: deno_core は組み込みを前提に設計されています。cli/runtime/ と密結合させることなく、独自のJavaScriptランタイムを構築するために単独で利用することもできます。libs/ に置かれているのはそのためです。

Extensionの抽象化

Extension は、ランタイムに新たな機能を追加するために必要なものをすべてひとまとめにした構造体です。

pub struct Extension {
  pub name: &'static str,
  pub deps: &'static [&'static str],
  pub js_files: Cow<'static, [ExtensionFileSource]>,
  pub esm_files: Cow<'static, [ExtensionFileSource]>,
  pub lazy_loaded_esm_files: Cow<'static, [ExtensionFileSource]>,
  pub ops: Cow<'static, [OpDecl]>,
  pub objects: Cow<'static, [OpMethodDecl]>,
  pub external_references: Cow<'static, [v8::ExternalReference]>,
  pub global_template_middleware: Option<GlobalTemplateMiddlewareFn>,
  pub global_object_middleware: Option<GlobalObjectMiddlewareFn>,
  pub op_state_fn: Option<Box<OpStateFn>>,
  pub needs_lazy_init: bool,
  pub enabled: bool,
}

deps フィールドは初期化順序の依存関係を宣言します。たとえば deno_fsdeno_web に依存します。JavaScriptソースは3種類の形式で提供できます。通常のJSファイル、ESモジュール、そして初めてインポートされたときだけ評価される遅延ロードESモジュールです。op_state_fn コールバックを使うと、初期化時に OpState へ状態を注入できます。

deno_ops が生成する extension!() マクロは、Extensionを宣言するための定型コードを自動生成します。deno_fs での使い方を ext/fs/lib.rs で確認してみましょう。

deno_core::extension!(deno_fs,
  deps = [ deno_web ],
  ops = [
    op_fs_open_sync, op_fs_open_async,
    op_fs_mkdir_sync, op_fs_mkdir_async,
    op_fs_chmod_sync, op_fs_chmod_async,
    // ... ~60 more ops
  ],
  esm = [ "30_fs.js" ],
  options = { ... },
  state = |state, options| { ... },
);

このマクロは deno_fs::init()deno_fs::lazy_init() という2つの関数を生成し、どちらも設定済みの Extension インスタンスを返します。lazy_init() バリアントは needs_lazy_init: true を設定し、op_state_fn コールバックの実行を後回しにします。これは、後述する UnconfiguredRuntime パターンにとって重要な仕組みです。

#[op2] Procマクロ

すべての op は、#[op2] アノテーションを付けた通常のRust関数として始まります。libs/ops/lib.rs のエントリーポイントは一見シンプルです。

#[proc_macro_attribute]
pub fn op2(attr: TokenStream, item: TokenStream) -> TokenStream {
  op2_macro(attr, item)
}

しかしこの裏では、op2 モジュールが大量のコードを生成しています。sync opの場合、生成されるのは次の3つです。

  1. スローパスv8::FunctionCallbackInfo から引数を取り出し、serde_v8 やカスタム変換を通じてRust関数を呼び出し、戻り値をV8に変換するV8関数コールバック
  2. ファストパス — 単純な引数型に対してV8のFast API(CFunction)を使用し、FunctionCallbackInfo のオーバーヘッドを回避する経路
  3. メトリクスラッパー — opトレースが有効な場合に、実行時間と成否を記録するラッパー
flowchart TD
    JS["JavaScript call:<br/>Deno.readFileSync(path)"]
    V8["V8 engine"]
    FAST{Fast API<br/>eligible?}
    FASTPATH["Fast CFunction call<br/>Direct type mapping"]
    SLOWPATH["Slow FunctionCallback<br/>Extract from FunctionCallbackInfo"]
    CONVERT["Type conversion<br/>(serde_v8 / custom)"]
    RUST["Rust op function<br/>op_fs_read_file_sync()"]
    RESULT["Convert result to V8"]
    
    JS --> V8
    V8 --> FAST
    FAST -->|Yes| FASTPATH
    FAST -->|No| SLOWPATH
    FASTPATH --> RUST
    SLOWPATH --> CONVERT
    CONVERT --> RUST
    RUST --> RESULT
    RESULT --> V8

#[op2] マクロはいくつかの呼び出し規約をアトリビュートで指定できます。#[op2(async)] は Futureを返す非同期op、#[op2(fast)] はファストパスを強制生成、#[op2(reentrant)] はJavaScriptへのコールバックが発生しうるopに使います。非同期opは impl Future を返し、op_driver モジュールのイベントループ統合によって駆動されます。

生成される OpDecl 構造体には、スローパスとファストパスの両方の関数ポインタが格納されます。

pub struct OpDecl {
  pub name: &'static str,
  pub is_async: bool,
  pub arg_count: u8,
  pub(crate) slow_fn: OpFnRef,
  pub(crate) slow_fn_with_metrics: OpFnRef,
  pub(crate) fast_fn: Option<CFunction>,
  pub(crate) fast_fn_with_metrics: Option<CFunction>,
  // ...
}

ヒント: op名には命名規則があります。op_fs_read_file_sync がsyncバリアント、op_fs_read_file_async がasyncバリアントです。30_fs.js のJavaScriptラッパーが状況に応じて適切な方を呼び出し、文字列を受け取ってパスに変換するといった、より使いやすいAPIを提供します。

実際のOpを追う:deno_fs

Deno.readFileSync("/tmp/hello.txt") の呼び出しをエンドツーエンドで追いかけながら、各ピースがどのようにつながるかを見ていきましょう。

ext/fs/lib.rs のExtension宣言で op_fs_read_file_sync が約60個のopのひとつとして登録されます。30_fs.js のJavaScriptレイヤーは ext:core/ops からこのopをインポートし、パーミッションチェックと引数バリデーションを加えてラップします。

sequenceDiagram
    participant JS as JavaScript (30_fs.js)
    participant Core as ext:core/ops
    participant V8 as V8 Engine
    participant Op as op_fs_read_file_sync
    participant FS as FileSystem trait
    participant Disk as std::fs

    JS->>Core: Deno.readFileSync(path)
    Core->>V8: Function call dispatch
    V8->>Op: slow_fn / fast_fn callback
    Op->>Op: Extract OpState
    Op->>Op: Check permissions
    Op->>FS: fs.read_file_sync(path)
    FS->>Disk: std::fs::read(path)
    Disk-->>FS: Vec<u8>
    FS-->>Op: Result<Vec<u8>>
    Op-->>V8: v8::Uint8Array
    V8-->>JS: Uint8Array

ext/fs/interface.rsFileSystem traitは抽象化レイヤーです。CLIは std::fs を内部で使う RealFs を採用しますが、テストやスタンドアロンバイナリでは別の実装を差し込めます。traitによるシステムアクセスの抽象化は、Denoのextensionシステム全体に共通するパターンです。

op関数自体は、第一引数としてマクロが注入した &mut OpState を受け取り、そこから FileSystem 実装と PermissionsContainer を借用します。パーミッションチェックはJavaScriptではなくRustで行われます。opこそがDenoのセキュリティモデルの執行境界なのです。

V8スナップショット:ビルド時のシリアライズ

Denoの高速起動は、V8スナップショットに大きく依存しています。ビルド時に runtime/snapshot.rs が、すべてのExtensionのJavaScriptコードをパース・コンパイル済みの状態でシリアライズしたV8ヒープを生成します。

pub fn create_runtime_snapshot(
  snapshot_path: PathBuf,
  snapshot_options: SnapshotOptions,
  custom_extensions: Vec<Extension>,
) {
  let mut extensions: Vec<Extension> = vec![
    deno_telemetry::deno_telemetry::lazy_init(),
    deno_webidl::deno_webidl::lazy_init(),
    deno_web::deno_web::lazy_init(),
    // ... ~30 extensions in specific order
  ];
  extensions.extend(custom_extensions);

  let output = create_snapshot(CreateSnapshotOptions {
    extensions,
    startup_snapshot: None,
    // ...
  }, None).unwrap();
  
  let mut snapshot = std::fs::File::create(snapshot_path).unwrap();
  snapshot.write_all(&output.output).unwrap();
}

実行時にはこのスナップショットが JsRuntime::new()startup_snapshot パラメータとして渡され、V8ヒープの状態が即座に復元されます。ExtensionのJavaScriptモジュールはすべてパース・コンパイル・部分評価済みの状態で読み込まれ、遅延ロードのExtensionだけがJavaScript評価を後回しにします。

sequenceDiagram
    participant Build as Build Time
    participant Snap as create_runtime_snapshot()
    participant V8B as V8 (build)
    participant File as snapshot.bin
    participant Runtime as Runtime (startup)
    participant V8R as V8 (runtime)

    Build->>Snap: Register ~30 extensions
    Snap->>V8B: Execute all JS sources
    V8B->>V8B: Parse, compile, evaluate
    V8B->>File: Serialize V8 heap
    Note over File: ~10MB snapshot blob
    Runtime->>File: Load snapshot bytes
    File->>V8R: Deserialize V8 heap
    Note over V8R: All JS modules ready
    V8R->>Runtime: JsRuntime ready in ~5ms

スナップショットにはすべてのExtensionのJavaScript側が含まれますが、Rust側のopバインディングは含まれません。関数ポインタはシリアライズをまたいで生き残れないためです。だからこそ skip_op_registration オプションが存在します。スナップショットからロードする際は、すでに存在するV8関数オブジェクトにop関数を再バインドするだけで済みます。

Extensionの登録順序

common_extensions() 関数は、厳密な順序を守りながら約30個のExtensionを登録します。

fn common_extensions<...>(has_snapshot: bool, unconfigured_runtime: bool) -> Vec<Extension> {
  // NOTE(bartlomieju): ordering is important here, keep it in sync with
  // `runtime/worker.rs`, `runtime/web_worker.rs`, `runtime/snapshot_info.rs`
  // and `runtime/snapshot.rs`!
  vec![
    deno_telemetry::deno_telemetry::init(),
    deno_webidl::deno_webidl::init(),
    deno_web::deno_web::lazy_init(),
    deno_webgpu::deno_webgpu::init(),
    deno_image::deno_image::init(),
    deno_fetch::deno_fetch::lazy_init(),
    // ... 20+ more in specific order
    deno_node::deno_node::lazy_init::<...>(),
    ops::bootstrap::deno_bootstrap::init(...),
    runtime::init(),
    ops::web_worker::deno_web_worker::init().disable(),
  ]
}

この関数の冒頭にあるコメントは重要な警告です。この順序は worker.rsweb_worker.rssnapshot_info.rssnapshot.rs4つのファイルにわたって同期を保たなければなりません。スナップショットがA順で構築されたのに、ランタイムがB順でExtensionを登録すると、op IDがずれてすべてが静かに壊れます。

最後のエントリーにも注目してください。deno_web_worker::init().disable().disable() 呼び出しは、すべてのop関数をnoopに差し替えます。opを登録することでJavaScriptの import 文が失敗しないようにしつつ、workerでないコンテキストから呼び出された場合はpanicさせる、という巧みな回避策です。

graph LR
    subgraph "Extension Registration Order"
        T[telemetry] --> WI[webidl] --> W[web] --> WG[webgpu]
        WG --> IMG[image] --> F[fetch] --> CA[cache]
        CA --> WS[websocket] --> WST[webstorage] --> CR[crypto]
        CR --> FFI[ffi] --> N[net] --> TLS[tls]
        TLS --> KV[kv] --> CRON[cron] --> NAPI[napi]
        NAPI --> HTTP[http] --> IO[io] --> FS[fs]
        FS --> OS[os] --> PROC[process] --> NC[node_crypto]
        NC --> NS[node_sqlite] --> NODE[node]
        NODE --> RT[runtime ops] --> BS[bootstrap]
    end

UnconfiguredRuntimeによる最適化

UnconfiguredRuntime パターンは、JsRuntime の生成を2つのフェーズに分割します。V8の初期化(フラグが揃う前でも早期に実行可能)と、設定(モジュールローダー・パーミッション・各種サービスが必要)です。

MainWorker::from_options() では、UnconfiguredRuntime が利用可能な場合にモジュールローダーを渡してハイドレーションします。

let mut js_runtime = if let Some(u) = options.unconfigured_runtime {
  let js_runtime = u.hydrate(services.module_loader);
  // ... reload cron handler from op state
  js_runtime
} else {
  // Full initialization path
  let mut extensions = common_extensions::<...>(...);
  common_runtime(CommonRuntimeOptions { ... })
};

common_runtime() 関数は、未設定のランタイムがない場合にすべてのExtension・スナップショット・設定を JsRuntime::new() に渡してゼロから JsRuntime を生成します。

ハイドレーション後、lazy_init() として登録されていたExtensionは js_runtime.lazy_init_extensions() を通じて状態を注入されます。ここで op_state_fn コールバックが実行され、blobストア・fetchオプション・キャッシュバックエンドなど、ランタイム固有の状態が OpState に注入されます。

ヒント: UnconfiguredRuntimedeno serve で特に威力を発揮します。サーバーソケットの設定と並行してV8を事前初期化できるためです。Unixではさらに、実際のコマンド引数が到達する前にランタイムを生成しておく制御ソケットの仕組み(wait_for_start)まで用意されています。

次回に向けて

RustからJavaScriptへの経路をひととおり追いました。ExtensionはopとJSソースをまとめ、#[op2] マクロがファストパスとスローパスのV8バインディングを生成し、スナップショットがビルド時にJSヒープ全体をシリアライズし、UnconfiguredRuntime が初期化を分割して最大限の並列性を実現します。次回はその逆方向、つまりJavaScriptモジュールがどのように解決・フェッチ・トランスパイル・実行されるかを追います。モジュールローディングパイプライン、リゾルバースタック、そしてDenoの二重TypeScriptコンパイルシステムを解説します。