Dive into Deno:プロセス起動からTypeScriptが実行されるまで

 · 47 min read

Denoのコードを読んでみました。
Rust に入門したばかりで基礎知識が足らず四苦八苦していますが、Deno のプロセスが起動してから TypeScript のコードが実行されるまでの仕組みについて愚直に読んでみたメモです。

想定読者

  • Deno の内部挙動に興味がある
  • Node.js、TypeScript、C++(と V8)のコードがドキュメントを参照しつつ読める
  • Rust で Hello world したことある程度の経験がある

参考情報

コア内部を理解するには非公式ガイド(以下ガイド)がとても参考になります。

A Guide to Deno Core - A Guide to Deno Core

Deno のディレクトリ構成やレイヤー分けについてはRepo StructureInfrastructureを一読し、リポジトリの構造をざっくり把握してからコードを読み始めるとより捗ると思います。

また、すでにmizchiさんがDeno のコードを読んだメモを上げてました。そちらもとても参考になりました。

Deno のビルドツール

まず Deno のビルドツールについて軽く触れます。
Deno ではgnという Node.js でおなじみの gyp の次期バージョンをビルドツールに用いています。

What is GN? GN is a meta-build system that generates NinjaBuild files. It’s meant to be faster and simpler than GYP. It outputs only Ninja build files.

What is GN?

コードを読む上でビルド設定のすべてを理解する必要はありませんが、これを読まないと次に実行されるコードがわからない、このコードがどこから出現したかわからないってことが結構あるので、必要に応じてビルド設定も併せて読むと読み進められると思います。

src/main.rs の main 関数のアウトライン

denoコマンドを実行したときに真っ先に実行されるファイルはsrc/main.rsです。ビルドツールでdenoコマンドを生成する際のエントリポイントとしてsrc/main.rsが指定されています。

Lines 127 to 129 in f9b167d

rust_executable("deno") {
  source_root = "src/main.rs"

main.rs の詳細に入る前にmain関数のアウトラインを整理し、用語の補足をしてから次に進みます。またガイドのRust main() Entry Pointにも説明があるので併せてご覧ください。

  1. ロガーをセット
  2. コマンドライン引数をパース
  3. --helpオプションをチェックし、あればヘルプを表示して終了
  4. コマンドライン引数に応じてログレベルを設定
  5. コマンドライン引数をもとにArc<IsolateState>インスタンスを生成
  6. snapshot::deno_snapshotで V8 スナップショットを取得
  7. V8 スナップショットから V8 Isolate(以下 Isolate)インスタンスを生成
  8. tokio_util::initで Tokio を初期化
  9. 生成した Isolate で JS のコードdenoMain();を評価する
  10. コマンドライン引数で与えられたファイルパスを評価する(もしくは REPL を起動)
  11. イベントループを開始

という流れになっています。用語を補足してから詳細を読み進めます。すでに知ってる方は先へお進み下さい。

ロガーや--help周りはコード量も少ないしシンプルなので解説は割愛します。またイベントループに関してはかなり長くなると判断したので本記事では割愛します。イベントループについてはガイドのisolate.event_loop()にて詳しく説明されているのでそちらを参照して下さい。

[補足] Isolate とは

Isolate は V8 が提供している API の1つです。
Deno にこれをラップした isolate.rs などがありますが、末端までコードを読むと結局は V8 の Isolate を操作しています。

Isolate An isolate is a concept of an instance in V8. In Blink, isolates and threads are in 1:1 relationship. One isolate is associated with the main thread. One isolate is associated with one worker thread. An exception is a compositor worker where one isolate is shared by multiple compositor workers.

Design of V8 bindings

VM よりコンテナよりもさらに軽量な分離技術、V8 の Isolate を用いてサーバレスコンピューティングを提供する Cloudflare Workers - Publickey

[補足] V8 スナップショットとは

V8 には生成された Isolate Context の状態をシリアライズしておき、それをデシリアライズして利用することでオーバーヘッドを削減し Isolate Context の生成を高速化する スナップショットの機能があります。

Node.js でも V8 スナップショットを使用してプロセスの起動を高速化する RFC が#17058にて議論されています。
その Issue のより詳細な資料Speeding up Node.js startup using V8 snapshotによると V8 スナップショットを使うことで、プロセスの起動時間を最大で2桁倍高速化できる見立てがあるようです。

「このタイプの callback 関数があるから、この Object を用意して、この値を設定して」とやっていくのは効率が良くありません。「一通り定義したらこれだけのメモリが必要で、こんなレイアウトになってるから」という感じにできないでしょうか? そこで我々は V8 が用意してくれていた Snapshot の機能を使って効率化することにしました。ちなみに純粋な V8 でも同様に Context を作る度に Math などの Built-in object を作るのが非効率ということで、この Snapshot 機能を作ったという背景があります。

V8 Context の作成を Snapshot を使って高速化した話 - Qiita

[補足] Arc とは

Arc って名前から全くピンとこなかったのですが、Atomically Reference Counted の略だそうです。
Atomic がついてないシングルスレッド版のrcという crate もあるようです。

A thread-safe reference-counting pointer. ‘Arc’ stands for ‘Atomically Reference Counted’.

std::sync::Arc - Rust

スレッドをまたいで参照を共有するために、Rust は Arc<T> というラッパ型を提供しています。

並行性

[補足] tokio とは

私もきちんと理解できていないので引用だけにとどめます。
tokio 周りは読み飛ばしてもコードの流れは追えます。どの処理で・どんな単位で並列化/多重化してるかについてしっかり読む場合は tokio と Futures の理解が必要になります。

Tokio is an event-driven, non-blocking I/O platform for writing asynchronous applications with the Rust programming language.

tokio-rs/tokio: A runtime for writing reliable, asynchronous, and slim applications with the Rust programming language.

futures::{Future, Stream} で実装された非同期かつゼロコストなグリーンスレッドを使ってネットワークプログラミングするためのフレームワーク

Tokio と Future のチュートリアルとかのまとめ+α - かっこかり(仮)

コマンドライン引数のパース

それでは main のアウトラインに沿って詳細を読んでいきます。
まずコマンドライン引数のパース周りの処理はsrc/flags.rsにあります。
パース処理は、まず V8 用のオプションのパースから実行されます。v8_set_flags関数です。この関数で V8 用のオプションとそれ以外(Deno の引数)を分離し、V8 用の引数はlibdeno/api.cc のdeno_set_v8_flagsに渡し、Deno 用の引数は後続の処理に引き継ぎます。
libdeno::deno_set_v8_flags が定義されているのはlibdeno/api.ccです。

Lines 103 to 105 in f9b167d
void deno_set_v8_flags(int* argc, char** argv) {
  v8::V8::SetFlagsFromCommandLine(argc, argv, true);
}

C++で定義された関数を Rust から呼び出すための FFI の定義がsrc/libdeno.rsに書かれています。

Lines 129 to 132 in f9b167d
extern "C" {
  pub fn deno_init();
  pub fn deno_v8_version() -> *const c_char;
  pub fn deno_set_v8_flags(argc: *mut c_int, argv: *mut *mut c_char);

V8 のオプションか否かを判定する方法はコメントによるとlibdeno::deno_set_v8_flags内部で呼び出されているv8::V8::SetFlagsFromCommandLineの内部に書かれており、引数で渡したコマンドラインオプションのうち、V8 が解釈できたオプションだけ取り除かれるという破壊的操作になってるようですようです。

Lines 301 to 303 in c870cf4
  // deno_set_v8_flags(int* argc, char** argv) mutates argc and argv to remove
  // flags that v8 understands.
  // First parse core args, then convert to a vector of C strings.

V8 が解釈できなかった残りのオプションのパース処理は Rust のgetoptsという crate をラップした独自関数を用いています getopts は予期しないオプションが渡されたときにエラー扱いになるのですが、そのエラーを自前でハンドルするためにラップしたset_recognized_flagsという関数を使用しています。“better solution welcome!”だそうです。なお Deno に指定可能なオプションはset_flags関数を読めばわかります。

Lines 62 to 64 in f9b167d
  // getopts doesn't allow parsing unknown options so we check them
  // one-by-one and handle unrecognized ones manually
  // better solution welcome!

コマンドライン引数からArc<IsolateState>インスタンスを生成

isolate::IsolateState::newsrc/isolate.rsに定義されていますが、これ自体はただの構造体を初期化してるだけだったので詳細を割愛。

V8 スナップショットを取得

snapshot::deno_snapshotsrc/snapshot.rsに定義されています。

Lines 4 to 14 in c870cf4
pub fn deno_snapshot() -> deno_buf {
  #[cfg(not(feature = "check-only"))]
  let data =
    include_bytes!(concat!(env!("GN_OUT_DIR"), "/gen/snapshot_deno.bin"));
  // The snapshot blob is not available when the Rust Language Server runs
  // 'cargo check'.
  #[cfg(feature = "check-only")]
  let data = vec![];

  unsafe { deno_buf::from_raw_parts(data.as_ptr(), data.len()) }
}

{GN_OUT_DIR}/gen/snapshot_deno.binというファイルから V8 スナップショットを取得しています。このファイルは gn でビルドする時に生成されているようです。環境変数GN_OUT_DIRの値はデバッグビルドした環境ではtarget/debugになってました。

スナップショットを生成するビルドタスクは BUILD.gn のsnapshot("snapshot_deno")に定義があります。snapshotというテンプレートはlibdeno/deno.gniに定義されていました。.gniは GN のビルド設定を別ファイルに定義して import できるようにしたものだそうです。

You can import .gni files into the current scope with the import function.

GN Language and Operation

libdeno/deno.gniの中にtool = "//libdeno:snapshot_creator"と指定があるように、V8 スナップショットを生成するファイルはlibdeno/snapshot_create.ccです。main 関数に渡される引数はビルド定義を読んでみると第一引数が出力先(gen/snapshot_deno.bin)のパス、第二引数が スナップショットを取りたい js(bundle/main.js)のパスになっていました。 main 関数の内部処理としては、deno_executeで与えられた js(bundle/main.js)を実行した結果の Isolate Context の スナップショットをdeno_get_snapshotで取得し、指定されたパス(gen/snapshot_deno.bin)に保存するという感じです。

Lines 22 to 30 in c870cf4
    inputs = [
      invoker.source_root,
    ]

    outputs = [
      snapshot_out_bin,
    ]
    args = rebase_path(outputs, root_build_dir) +
           rebase_path(inputs, root_build_dir)

なお前触れなく登場したbundle/main.jsとはrollupで生成された Deno の TypeScript のコード bundle した生成物です。

$ head -n5 target/debug/gen/bundle/main.js
var denoMain = (function () {
  'use strict';

  var runner = /*#__PURE__*/Object.freeze({
    get Runner () { return Runner; }

ここからさらにビルド周りを深追いしていくと submodule になっているdenoland/chromium_buildなどの別リポジトリにたどり着くのですが、ビルドプロセスを深堀りしすぎると本筋から逸脱するのでこれぐらいにします。

V8 スナップショットから Isolate インスタンスを生成

isolate::Isolate::newIsolateStateと同じくsrc/isolate.rsに定義されています。
渡している引数は上の行で生成した V8 snapshot、2つ上の行で生成した IsolateState、opt::dispatchの3つです。opt::dispatchに関してはひとまずこちらを参照。後々denoMain関数を実行するあたりでまた出てきます。

第三引数の dispatch ってなんだ、と思ったらコメントに色々書いてある。
js からの来る諸々を Rust で捌いてる部分っぽく見える。

deno_code_reading.md

isolate::Isolate::newのアウトラインは、

  1. (プロセス中で1回だけ)libdeno::deno_init()で初期化
  2. 引数で受け取った スナップショットを使ってlibdeno::deno_configのインスタンスを生成
  3. 生成したdeno_configdeno_newに渡してlibdeno_isolateのインスタンスを生成
  4. 並列処理のメッセージングに使うチャネルを生成
  5. Isolate インスタンス作って返却

という感じになっています。

libdeno::deno_init()は V8 の初期化をしています。

deno_newlibdeno/api.ccに定義されています。
先程のスナップショットを生成する処理でも登場しており、そのときはwill_snapshotが 1、load_snapshotdeno::empty_bufになっていました。今回はdeno_newwill_snapshotを 0、load_snapshotに生成された スナップショットを指定しています。それぞれの値の違いによる処理の分岐はこのあたりを見れば明らかかと思います。

Lines 40 to 51 in f9b167d
Deno* deno_new(deno_config config) {
  if (config.will_snapshot) {
    return deno_new_snapshotter(config);
  }
  deno::DenoIsolate* d = new deno::DenoIsolate(config);
  v8::Isolate::CreateParams params;
  params.array_buffer_allocator = d->array_buffer_allocator_;
  params.external_references = deno::external_references;

  if (config.load_snapshot.data_ptr) {
    params.snapshot_blob = &d->snapshot_;
  }

スナップショットを用いてV8::Isolateのインスタンスを作ってスコープ設定して…とわちゃわちゃやってますが、このあたりは V8 自体の説明になってしまうので割愛します。この記事とガイドにて丁寧な解説されているので理解の助けになると思います。

V8 の基本的な API を学ぶ - Qiita

There are 2 important functions/constructors used in deno_new that might not be immediately clear: DenoIsolate and InitializeContext. It turns out DenoIsolate serves more or less as a collection of Isolate information. Instead, InitializeContext is the more interesting one.

Interaction with V8 - A Guide to Deno Core

生成した Isolate でdenoMain関数を実行

Lines 89 to 92 in f9b167d
    // Setup runtime.
    isolate
      .execute("denoMain();")
      .unwrap_or_else(print_err_and_exit);

ここですね。isolate.execute自体はごくシンプルで、内部でibdeno::deno_executeを読んでるだけです。

CString って C にわたす FFI 呼ぶときによく見るやつだ。 実質 libdeno::deno_execute() へのファサードになっている。

deno_code_reading.md

The only interesting function we care in this section is libdeno::deno_execute. We can find its actual definition in libdeno/api.cc again:

Interaction with V8 - A Guide to Deno Core

deno_executeは スナップショット作るところで1度登場していますが、そこでは読み飛ばしたので詳しく読んでみます。V8 Context を作ってdeno::Executeに渡してます。

deno::Executelibdeno/binding.ccに定義されています。
おおまかな流れとしては渡された文字列をv8::Script::Compileでコードをコンパイルし、エラーがなければLocal<Script>->Run(context)でコードを実行して、エラーチェックして終わりです。

Lines 636 to 667 in c870cf4
bool Execute(v8::Local<v8::Context> context, const char* js_filename,
             const char* js_source) {
  auto* isolate = context->GetIsolate();
  v8::Isolate::Scope isolate_scope(isolate);
  v8::HandleScope handle_scope(isolate);
  v8::Context::Scope context_scope(context);

  auto source = v8_str(js_source, true);
  auto name = v8_str(js_filename, true);

  v8::TryCatch try_catch(isolate);

  v8::ScriptOrigin origin(name);

  auto script = v8::Script::Compile(context, source, &origin);

  if (script.IsEmpty()) {
    DCHECK(try_catch.HasCaught());
    HandleException(context, try_catch.Exception());
    return false;
  }

  auto result = script.ToLocalChecked()->Run(context);

  if (result.IsEmpty()) {
    DCHECK(try_catch.HasCaught());
    HandleException(context, try_catch.Exception());
    return false;
  }

  return true;
}

まとめると、isolate.execute("denoMain();")denoMain();という文字列(JavaScript のコード)を V8 でコンパイルして実行しています。
いきなりdenoMain関数を呼び出してますがdenoMainとはどこから来たのでしょうか。これまでのコードでは denoMain 関数を定義するようなコードは登場しませんでした。おそらく V8 スナップショットから復元されています。
あらかじめdenoMainの関数定義を含んだ bundle/main.js を実行した結果をスナップショットとして保存し、それを使って Isolate を復元しているのでdenoMain関数が呼べるようになっています。

前触れなく登場しているbundle/main.jsとは、js の bundler の1つであるrollupで生成されたdenoMain関数を含む deno の js レイヤのコードです。

引用したこのあたりです。demoMain 関数を呼び出せる仕組みがわかったので denoMain について詳しく読んでみます。

demoMain 関数

Lines 59 in c870cf4
export default function denoMain() {

要はここまでやって v8 が起動していることがわかった。じゃあどういうスクリプトが起動しているのか。

deno_code_reading.md

やっと Deno の TypeScript のレイヤにたどり着けました。といっても実際には rollup で bundle された JavaScript が実行されており TypeScript を直接実行しているわけではありません。

denoMain関数が定義されているjs/main.tsは大きく分けて3パートに分かれています。

  1. denoMain
  2. sendStart
  3. compilerMain

denoMainは(コマンドライン)引数を処理して、sendStartをコールするなどの然るべき初期化を行います。
sendStartsendSyncという関数を用いて js レイヤの初期化が開始したことを C++レイヤへ通知しています。
compilerMainはここでは呼び出されていません。関数定義だけして window オブジェクトのプロパティにセットしています。この関数は後々 ts をコンパイルするところで再び登場します。

Lines 60 in c870cf4
  libdeno.recv(handleAsyncMsgFromRust);

denoMain 関数の冒頭にあるlibdeno.recvhandleAsyncMsgFromRustは、イベントリスナとイベントハンドラのようなものです。

[補足] libdenoオブジェクト

First, we tell libdeno, which is the exposed API from the middle-end, that whenever there is a message sent from the Rust side, please forward the buffer to a function called handleAsyncMsgFromRust. Then, a Start message is sent to Rust side, signaling that we are starting and receiving information including the current working directory, or cwd. We then decide whether the user is running a script, or using the REPL. If we have an input file name, Deno would then try to let the runner, which contains the TypeScript compiler, to try running the file (going deep into its definition, you’ll eventually find a secret eval/globalEval called somewhere). denoMain only exits when the runner finish running the file.

Process Lifecycle - A Guide to Deno Core

denoMain の中にlibdenoという名前空間的なオブジェクトが登場しますが、このオブジェクトの定義はjs/libdeno.tsには存在しません。どこかで事前に作られています。
libdeno オブジェクトはlibdeno/api.ccdeno_new関数の内部処理で読み飛ばしたInitializeContextにて C++から V8 を直接操作して生成されています。

Lines 675 to 676 in c870cf4
  auto deno_val = v8::Object::New(isolate);
  CHECK(global->Set(context, deno::v8_str("libdeno"), deno_val).FromJust());

これでlibdenoという空オブジェクトを作成し、その中にprint, recv, sendメソッドの実装をセットしています。

Lines 678 to 688 in c870cf4
  auto print_tmpl = v8::FunctionTemplate::New(isolate, Print);
  auto print_val = print_tmpl->GetFunction(context).ToLocalChecked();
  CHECK(deno_val->Set(context, deno::v8_str("print"), print_val).FromJust());

  auto recv_tmpl = v8::FunctionTemplate::New(isolate, Recv);
  auto recv_val = recv_tmpl->GetFunction(context).ToLocalChecked();
  CHECK(deno_val->Set(context, deno::v8_str("recv"), recv_val).FromJust());

  auto send_tmpl = v8::FunctionTemplate::New(isolate, Send);
  auto send_val = send_tmpl->GetFunction(context).ToLocalChecked();
  CHECK(deno_val->Set(context, deno::v8_str("send"), send_val).FromJust());

見ての通りこの3つの実装は C++で定義されており、V8 を操作して C++の関数ポインタを js 側に露出しています。
printここで定義されており、ざっくりまとめるとSTDOUTもしくはSTDERRのどちらかに渡された引数を fwrite している。要は printf 的なものです。
recvここに定義されており、ざっくりまとめると渡された引数を関数としてキャストし、DenoIsolate のrecv_プロパティにコールバック関数としてセットしています。
sendここに定義されています。src/isolate.rsの中でdeno_new関数の中で渡しているdeno_configrecv_cbDenoIsolateに渡っており、結果的にpre_dispatchという Rust の関数が呼び出されます。
という C++(Rust)レイヤとやりとりするためのlibdenoオブジェクトが定義されていました。

Send に関してはガイドの説明も併せて参照してください。

The Send function get args and invoke the recvcb . Notice that the recvcb is defined in the Rust code.

Under the call site’s hood - A Guide to Deno Core

(ファイルパスが与えられていたら)それをモジュールとして実行

denoMainが実行され、Deno ランタイムの準備が整いました。
再び Rust の main 関数に話題を戻し、次はファイルパスがコマンドライン引数として与えられてるときisolate.execute_modが呼び出されるあたりを読みます。
これまでも FFI で C++, Rust, js と複数レイヤをまたぐコードが多かったですが、ここから先も複雑かつ長いので、休憩しつつ読まれるといいと思います。

Lines 94 to 100 in c870cf4
    // Execute input file.
    if isolate.state.argv.len() > 1 {
      let input_filename = &isolate.state.argv[1];
      isolate
        .execute_mod(input_filename, should_prefetch)
        .unwrap_or_else(print_err_and_exit);
    }

isolate.execute_modsrc/isolate.rsに定義されています。
isolate.executeと同じ要領でdeno_execute(filename, ファイルの中身)と実行するのかと思いましたが、違いました。内部で呼び出されているcode_fetch_and_maybe_compileという関数とlibdeno::deno_execute_modが気になります。まずcode_fetch_and_maybe_compileから読んでみます。

Lines 256 to 286 in c870cf4
  /// Executes the provided JavaScript module.
  pub fn execute_mod(
    &self,
    js_filename: &str,
    is_prefetch: bool,
  ) -> Result<(), JSError> {
    let out =
      code_fetch_and_maybe_compile(&self.state, js_filename, ".").unwrap();

    let filename = CString::new(out.filename.clone()).unwrap();
    let filename_ptr = filename.as_ptr() as *const i8;

    let js_source = CString::new(out.js_source().clone()).unwrap();
    let js_source = CString::new(js_source).unwrap();
    let js_source_ptr = js_source.as_ptr() as *const i8;

    let r = unsafe {
      libdeno::deno_execute_mod(
        self.libdeno_isolate,
        self.as_raw_ptr(),
        filename_ptr,
        js_source_ptr,
        if is_prefetch { 1 } else { 0 },
      )
    };
    if r == 0 {
      let js_error = self.last_exception().unwrap();
      return Err(js_error);
    }
    Ok(())
  }

code_fetch_and_maybe_compilesrc/isolate.rsに定義されています。

Lines 375 to 380 in f9b167d
fn code_fetch_and_maybe_compile(
  state: &Arc<IsolateState>,
  specifier: &str,
  referrer: &str,
) -> Result<CodeFetchOutput, DenoError> {
  let mut out = state.dir.code_fetch(specifier, referrer)?;

1行目のstate.dir.code_fetch型定義を読んでみるとdeno_dir::DenoDirというメソッドです。src/deno_dir.rsに定義されています。DenoDir#code_fetchのアウトラインとしては、

  1. resolve_moduleでモジュールのファイルパス(か URL)を解決
  2. get_source_code/https?/で始まるならfetch_remote_source、それ以外ならfetch_local_sourceを使用して TypeScript のコードを取得
  3. filter_shebangで shebang を取り除いて
  4. load_cacheでコンパイル後の JavaScript とその Source Map を手に入れる

となっております。
ここから先resolve_moduleやそれ以外の箇所でspecifierreferrerという変数がよく登場します。specifierはモジュールの名前・パス解決の処理全般で、import fromに記載した文字列(もしくはコマンドライン引数で与えたファイルパス)を現しています。referrerは import が記載されているファイルの絶対パス(もしくは URL)と念頭に置いておくとコードが読みやすいと思います。
get_source_codeにてローカルかリモートかにかかわらず返却されているCodeFetchOutput型の構造は以下のとおりです。

  • module_name: specifier
  • filename: 解決できたファイル名(もしくは URL)
  • media_type: msg::MediaType::TypeScriptmsg::MediaType::JavaScriptmsg::MediaType::Jsonmsg::MediaType::Unknownのいずれか
  • source_code: ファイル内容
  • maybe_output_code: (fetch_local_sourceの場合常に None)
  • maybe_source_map: (fetch_local_sourceの場合常に None)

ローカルのファイルパスを解決するfetch_local_sourceの面白い挙動としては、ファイル名.mimeというファイルがもし置かれていれば、そのメディアタイプとして解釈する挙動になっています。ローカルファイルでこれをわざわざやる必要は無さそうですが、リモートのファイルとを取ってくるときに Content-Type ヘッダの値を格納しておくという使い方をしています。
なお.mimeファイルがなければファイルの拡張子ごとにあらかじめ定義されているタイプとして解釈します。例えば.tsは TypeScript として解釈します。

リモートの URL を解決するfetch_remote_sourceではソースを HTTP GET してきて、URL 末尾の拡張子に対応するメディアタイプと、レスポンスヘッダのContent-Typeヘッダを比較し、もし食い違っていたら前述の.mimeタイプを作成し拡張子よりもContent-Typeの値をリスペクトするという処理になってます。

Lines 152 to 175 in c870cf4
    eprint!("Downloading {}...", &module_name); // no newline
    let maybe_source = http_util::fetch_sync_string(&module_name);
    if let Ok((source, content_type)) = maybe_source {
      eprintln!(""); // next line
      match p.parent() {
        Some(ref parent) => fs::create_dir_all(parent),
        None => Ok(()),
      }?;
      deno_fs::write_file(&p, &source, 0o666)?;
      // Remove possibly existing stale .mime file
      // may not exist. DON'T unwrap
      let _ = std::fs::remove_file(&media_type_filename);
      // Create .mime file only when content type different from extension
      let resolved_content_type = map_content_type(&p, Some(&content_type));
      let ext = p
        .extension()
        .map(|x| x.to_str().unwrap_or(""))
        .unwrap_or("");
      let media_type = extmap(&ext);
      if media_type == msg::MediaType::Unknown
        || media_type != resolved_content_type
      {
        deno_fs::write_file(&mt, content_type.as_bytes(), 0o666)?
      }

コードを取得するための HTTP クライアントはhyperというものを利用していました。低レベルだけど高速に動作する HTTP クライアントのようです。例えばリダイレクトのフォローを自前で書かないといけないくらいシンプルなクライアントです。コードにも TODO が書かれているんですが、現在のコードでは最大リダイレクト数が設定されておらず、リダイレクトループが発生するとハングするコードになってます。

Lines 61 to 62 in c870cf4
  // TODO(kevinkassimo): consider set a max redirection counter
  // to avoid bouncing between 2 or more urls

TypeScript のコードの取得は完了しました。次に V8 で実行できるように JavaScript にコンパイルする必要があります。 code_fetch_and_maybe_compileDenoDir::code_fetchを読みきったので続きを読んでいきます。

Lines 375 to 390 in 77114fb
fn code_fetch_and_maybe_compile(
  state: &Arc<IsolateState>,
  specifier: &str,
  referrer: &str,
) -> Result<CodeFetchOutput, DenoError> {
  let mut out = state.dir.code_fetch(specifier, referrer)?;
  if (out.media_type == msg::MediaType::TypeScript
    && out.maybe_output_code.is_none())
    || state.flags.recompile
  {
    debug!(">>>>> compile_sync START");
    out = compile_sync(state, specifier, &referrer).unwrap();
    debug!(">>>>> compile_sync END");
  }
  Ok(out)
}

わかりやすくログが出てますが、compile_syncが ts を js にコンパイルする関数ですね。compile_syncの中身も追います。

Lines 101 to 118 in 77114fb
pub fn compile_sync(
  parent_state: &Arc<IsolateState>,
  specifier: &str,
  referrer: &str,
) -> Option<CodeFetchOutput> {
  let req_msg = req(specifier, referrer);

  let compiler = lazy_start(parent_state);

  let send_future = resources::worker_post_message(compiler.rid, req_msg);
  send_future.wait().unwrap();

  let recv_future = resources::worker_recv_message(compiler.rid);
  let res_msg = recv_future.wait().unwrap().unwrap();

  let res_json = std::str::from_utf8(&res_msg).unwrap();
  CodeFetchOutput::from_json(res_json)
}

内部で呼び出されているlazy_startと、更にその中でコールされているworker::spawnも追います。

Lines 82 to 90 in 77114fb
fn lazy_start(parent_state: &Arc<IsolateState>) -> Resource {
  let mut cell = C_RID.lock().unwrap();
  let rid = cell.get_or_insert_with(|| {
    let resource =
      workers::spawn(parent_state.clone(), "compilerMain()".to_string());
    resource.rid
  });
  Resource { rid: *rid }
}

Lines 53 to 87 in 77114fb
pub fn spawn(
  state: Arc<IsolateState>,
  js_source: String,
) -> resources::Resource {
  // TODO This function should return a Future, so that the caller can retrieve
  // the JSError if one is thrown. Currently it just prints to stderr and calls
  // exit(1).
  // let (js_error_tx, js_error_rx) = oneshot::channel::<JSError>();
  let (p, c) = oneshot::channel::<resources::Resource>();
  let builder = thread::Builder::new().name("worker".to_string());
  let _tid = builder
    .spawn(move || {
      let (worker, external_channels) = Worker::new(&state);

      let resource = resources::add_worker(external_channels);
      p.send(resource.clone()).unwrap();

      tokio_util::init(|| {
        (|| -> Result<(), JSError> {
          worker.execute("denoMain()")?;
          worker.execute("workerMain()")?;
          worker.execute(&js_source)?;
          worker.event_loop()?;
          Ok(())
        })().or_else(|err: JSError| -> Result<(), JSError> {
          eprintln!("{}", err.to_string());
          std::process::exit(1)
        }).unwrap();
      });

      resource.close();
    }).unwrap();

  c.wait().unwrap()
}

worker::spawn の中worker.executeにてdenoMain(), workerMain(), 引数で渡されたコードを実行するようになっています。引数で渡されたコードは何かというとlazy_startを見ての通りcompilerMain()です。
Worker は main 関数で生成された V8 Isolate とは隔離された別の Isolate を生成しています。この Isolate もまたスナップショットから復元されているので高速です。 これら3つの関数が実行されたあとに、req 関数で生成したspecifierreferrerを worker に送信し、TypeScript のコンパイルを実行します。

次にcompilerMainの定義を追ってみます。

Lines 526 to 544 in ee9c627
// provide the "main" function that will be called by the privilaged side when
// lazy instantiating the compiler web worker
window.compilerMain = function compilerMain() {
  // workerMain should have already been called since a compiler is a worker.
  const encoder = new TextEncoder();
  const decoder = new TextDecoder();
  compiler.recompile = startResMsg.recompileFlag();
  log(`recompile ${compiler.recompile}`);
  window.onmessage = ({ data }: { data: Uint8Array }) => {
    const json = decoder.decode(data);
    const lookup = JSON.parse(json) as CompilerLookup;

    const moduleMetaData = compiler.compile(lookup.specifier, lookup.referrer);

    const responseJson = JSON.stringify(moduleMetaData);
    const response = encoder.encode(responseJson);
    postMessage(response);
  };
};

Compiler の compile メソッドはjs/compiler.tsに定義されています。コードがやや長いので引用はしません。
大まかな処理内容としては、typescriptモジュールを使ってコンパイルしたり型検査したりしてます。JSON に関してはjsonEsmTemplateという薄いラッパーを噛ませて独自コンパイルしています。それ以外(TypeScript, JavaScript)に関しては、LanguageService のgetEmitOutputを用いてソースマップとコンパイル後の JavaScript を入手し、その後に型検査 etc を実行しもしエラーがあれば異常終了、エラーがなければ JavaScript にソースマップをくっつけてキャッシュに書き込んで完了という処理になっています。
検査する項目は独自に絞って処理の高速化を図っています。このやり方のほうが 3 倍ほど通常の検査より高速だそうです。

Lines 262 to 274 in ee9c627
      // Get the relevant diagnostics - this is 3x faster than
      // `getPreEmitDiagnostics`.
      const diagnostics = [
        // TypeScript is overly opinionated that only CommonJS modules kinds can
        // support JSON imports.  Allegedly this was fixed in
        // Microsoft/TypeScript#26825 but that doesn't seem to be working here,
        // so we will ignore complaints about this compiler setting.
        ...service
          .getCompilerOptionsDiagnostics()
          .filter(diagnostic => diagnostic.code !== 5070),
        ...service.getSyntacticDiagnostics(fileName),
        ...service.getSemanticDiagnostics(fileName)
      ];

なお TypeScript の LanguageService の詳しい説明は公式の wikiにあったのでそちらを参照して下さい。

JavaScript へのコンパイルが完了し、ようやく指定されたモジュールを実行できるようになりました。最後にlibdeno::deno_execute_modを読みます。libdeno::deno_execute_modlibdeno/api.ccに定義されています。実質的な処理はdeno::ExecuteModに書いてあります。deno::ExecuteModlibdeno/binding.ccに定義されています。V8 で JavaScript のコードを実行するくだりはdeno::Executeのときに書いた説明を参照して下さい。

Lines 603 to 624 in c870cf4
  auto maybe_module = CompileModule(context, js_filename, source);

  if (maybe_module.IsEmpty()) {
    DCHECK(try_catch.HasCaught());
    HandleException(context, try_catch.Exception());
    return false;
  }
  DCHECK(!try_catch.HasCaught());

  auto module = maybe_module.ToLocalChecked();
  auto maybe_ok = module->InstantiateModule(context, ResolveCallback);
  if (maybe_ok.IsNothing()) {
    return false;
  }

  CHECK_EQ(v8::Module::kInstantiated, module->GetStatus());

  if (resolve_only) {
    return true;
  }

  auto result = module->Evaluate(context);

ここまでの内容で、deno コマンドを実行し、TypeScript が実行されるまでの処理が追えたと思います。

$ cat tests/002_hello.ts
console.log("Hello World");

$ ./target/debug/deno tests/002_hello.ts
Hello World

さいごに

実行されるすべての行を網羅したわけではありませんが、ビルドプロセスも含めて一連の流れを追えたと思います。Rust、C++、TypeScript とレイヤをまたぐ処理がかなり多いので、わかってしまえばすんなり読めますが、初見はかなり大変でした。
C++は V8 以外の処理をなるべく書かず意図的に Rust 側に寄せているように感じました。Rust で書いたほうが自分の足を撃ちにくいプログラムになると思うので、これが功を奏すのかどうか、1~2 年後が楽しみです。

記事内ではあまり触れてませんが、コードを読んでる過程で TODO や FIXME コメントが大量に見つかったので、Deno コアに対するコントリビュートのネタは、コード読むとたくさん見つかると思います。

他にも Worker の実装、セキュリティモデルの実装、TypeScript 製の外部モジュールの依存解決、イベントループや TaskQueue 関連の Node.js との差異、ビルドプロセスの詳細など、まだまだ読みたい Deno のコードがあるので、読んで理解できたら記事書きます。

JavaScriptTypeScriptRustDeno
© 2012-2021 Leko