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があった。
本体
- `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
#watchCallback()
- 初回起動時および、上述のファイル変更が検知されたら動く
- 初期化された`#modules`を使って、`#sandbox`と`#environment`を構築する
- つまり実行コンテキストはココで決まる
- その後、`#reloadScheduled()`と`#reloadWorker()`が呼ばれる
- コールバックを呼ぶときは、パース済のオプションで呼び出す
- その中には、実行指定されたスクリプト本体も含まれる
#reloadWorker()
scripts.ts
- `ScriptBlueprint`クラス
- `_createContext()`
- 用意したコンテキストのsandboxを使って、`vm.createContext()`
- https://nodejs.org/api/vm.html#vm_vm_createcontext_contextobject_options
- `buildScript()`
- 実行スクリプトを`vm.Script`クラスにして、`runInContext()`できるようにする
- https://nodejs.org/api/vm.html#vm_script_runincontext_contextifiedobject_options
まとめ
- `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