🍃このブログは移転しました。
3秒後、自動的に移動します・・・。

miniflare のコードを読む

https://github.com/mrbbot/miniflare

Cloudflare Workers(以下、CFW)相当の実行環境をローカルで再現できるアレです。

そんなんは公式が出してほしいな〜と思い続けてはや1年弱、いつまで経っても出てこない!
というわけで、コード読んでみたシリーズです。

そもそも、なぜローカルで動かしたいのか

これはひとえに、現状のCFWはローカルで開発できないから。

いちおう本家のCLIに`wrangler dev`という開発用のコマンドはあるけど、インターネットにプライベートなやつがデプロイされてそれを`localhost`にトンネルするだけで、実質ローカルではない。

そのうえ、

  • (インターネットに上げるからか)動作も速くない
  • そしてとにかくクラッシュする
  • 変更も反映されたりされなかったり謎
  • そのくせしっかり課金対象(無料枠の圧迫)

という感じで、あまり快適な開発体験とは言えないかなーというのが正直なところ。
もちろんあらゆるものが本番想定のインフラで動かせるというところにやや便利さはあるけど・・。

で、なんとかしたいなーとは思うものの、CFWの実行環境は「V8 Isolate + Cloudflare独自API」というちょっと特殊な感じになっていて、もちろんNodeでそのまま動くわけもなく。
ってなところで、力ずくでそれをやり遂げてNodeで動いちゃってるこの`miniflare`はすごいのである!

というわけで、読んでいきます。
コードはTypeScriptで書かれてるので、気合さえあれば読めるやつ。

この記事を書いた時点のバージョンは`1.3.2`でした。

外観

`miniflare`は、CLIとしても使えるほか、プログラムからも利用できる。

CLIなら`miniflare worker.js`のようにするし、プログラムからならこのように。

import { Miniflare } from "miniflare";

const mf = new Miniflare({
  script: `
  addEventListener("fetch", (event) => {
    event.respondWith(new Response("Hello Miniflare!"));
  });
  `,
});
const res = await mf.dispatchFetch("http://localhost:8787/");
console.log(await res.text()); // Hello Miniflare!

CLIは結局ラッパーなはずで、`Miniflare`クラスのために便利な初期設定をしてるだけと予想。

CLI

まず、`package.json`の`bin`に、`src/bootstrap`へのrefがあった。

bootstrap.ts

`--experimental-vm-modules`をつけて、`cli.ts`をキックしてるだけ。

このフラグを有効にすると、Nodeの`vm`モジュールから、

  • `Module`
  • `SourceTextModule`
  • `SyntheticModule`

この3つがさらに使えるようになるとのこと。

VM (executing JavaScript) | Node.js v16.5.0 Documentation

これらは、CFWのコードをES Modulesの形式で書くスタイルの場合に必要らしい。

cli.ts

  • CLIとしてのI/Oは、`yargs`を使ってる
    • 特別な処理はなく、引数パースしてるだけ
  • `export default`はおそらくテスト用
  • `if (module === require.main)`のブロックが本題
    • `Miniflare`クラスを、パースしたオプションで初期化
    • リクエストを処理するHTTP(S)のサーバーを立てる
    • 実行時に最新バージョンをnpmに確認して、アップデートあるよって知らせる

というわけで、想定どおり。メインはやはり本体の`index.ts`へ。

本体

  • `Miniflare`クラスがいるところ
    • それ以外にも`export`されてるけどとりあえず無視
  • 主要そうなプロパティ
    • `#modules`
    • `#watcher`
    • `#sandbox`
    • `#environment`
  • `#httpRequestListener()`
    • ローカルに立てるサーバーのハンドラ
    • CFW独自の`Request`オブジェクトも、`@mrbbot/node-fetch`で用意されててさすがだった
    • ScheduledEventか、FetchEventかを判定
    • 前者の場合は、`dispatchScheduled()`で処理
    • 後者の場合は、`dispatchFetch()`で処理

Modules, Sandbox, Environment

  • Moduleは、`Miniflare`のコンストラクタで`#modules`にアサインされるものたち
  • CFWの実行環境を構成する要素を、モジュールという名で関心ごとに実装してある
  • たとえば、
    • `EventsModule`の場合、グローバルな`addEventListener()`とか、`FetchEvent`とか
    • `StandardsModule`の場合、`fetch()`とか`crypto`とか
  • Moduleごとに、SandboxとEnvironmentを用意するようになってる
  • Sandboxは、Workerのグローバルスコープそのもの
    • どこのWorkerでもすべからく同じもの
  • Environmentは、シークレットや環境変数など
    • 人それぞれで違うかもしれないもの
    • KVなどもこっち扱い
  • NodeのAPIとnpm資産によって、CFWの独自APIまでぜんぶ実装してある・・・
    • しゅごい

OptionsWatcher

  • `Miniflare`のコンストラクタで`#watcher`にアサインされるやつ
  • 実行スクリプトや、設定ファイルなどの変更を監視する役割
    • `wrangler.toml`とかも
    • 監視自体は`chokidar`がやってる
  • 変更があったら、`#watchCallback()`が呼ばれる
    • コール結果としての`await #watcher.initPromise`が、各所でチェックされる仕組み

#watchCallback()

  • 初回起動時および、上述のファイル変更が検知されたら動く
  • 初期化された`#modules`を使って、`#sandbox`と`#environment`を構築する
    • つまり実行コンテキストはココで決まる
  • その後、`#reloadScheduled()`と`#reloadWorker()`が呼ばれる
  • コールバックを呼ぶときは、パース済のオプションで呼び出す
    • その中には、実行指定されたスクリプト本体も含まれる

#reloadWorker()

scripts.ts

`dispatchFetch()`

  • 上述のくだりでスクリプトが実行されて、イベントを待ち受けてるインスタンスがあるはず
  • そこに`localhost`で受けたHTTPを、イベントとして横流しする
  • さっきの`EventsModule`の`dispatchFetch()`を呼ぶ
    • 実行スクリプト側で呼んだはずの`addEventListener()`で対応

というのが一連の流れ。

まとめ

  • `modules/*`の各モジュールが、NodeでCFW相当の環境を実装してる要たち
    • WorkerGlobalScopeをNodeで実装してるだけですごいのに
    • KVとかDOとかのCFW独自APIまで全てが再実装されてる
  • 実行スクリプトは、Nodeの`vm`モジュールでV8 Isolate相当を再現
    • というか独自コンテキストでコード実行するならこうするしかない?
  • 初回起動時、依存ファイルの更新時に、コンテキストを再生成
  • あとは任意のリクエストを`localhost`で受けて、実行スクリプトインスタンスで実行

という感じ。うーむ、わかりやすい!

おわりに

  • コードがすごく読みやすくて感動した
    • `#sandbox`みたいなプライベートプロパティも使われてるモダンなコード
  • 間違いなくプロの犯行
    • というか精錬されすぎててもしや2周目ですか?ってなった
  • モジュールWorkerのためのコードが結構行数を取ってるのが少し気になる

存在は知っててもNodeの`vm`モジュールとか使ったこともなかったし、とっても勉強になった。

その恩返しも兼ねて、めちゃめちゃ小さいPRを出したら無事にマージされた 😆

ただもちろんCFW本家と100%同等ではないし、微妙な違いはあるっぽいけど、まあローカルで開発するだけなら便利に使えるやつなのかなーと。

Missing EventTarget and Event · Issue #18 · mrbbot/miniflare · GitHub