2021年7月、私はWorkers向けの遊び心を持ちフル機能を備えた完全ローカルシミュレータであるMiniflare 1.0を、Cloudflare Workers Discordサーバ上に立ち上げました。cloudflare-worker-local
プロジェクトへのプルリクエストとして始まったものが今ではCloudflareの公式プロジェクトとなり、Workersのエコシステムの中核となってwrangler 2.0に統合されています。本日、次のメジャーバージョンである、よりモジュール化された、軽量で正確なMiniflare 2.0のリリースを発表できることを嬉しく思います。?
背景:Miniflareが作られた理由
2020年末頃に、私は初めてとなるWorkersアプリの制作を始めました。最初は、当時最新のリリースである wrangler dev
を使用していましたが、これは変更が反映されるまでに数秒かかることがわかりました。Workersのランタイム上で動作していることを考えると十分凄いことではありますが、私はフロントエンドの開発にViteを使用していたので、開発者体験の大幅な高速化が可能であることを知っていました。
その後、ローカルのWorkersシミュレータであるcloudflare-worker-local
とcloudworker
に目を付けましたが、Workers Sitesのような新しい機能はサポートされていませんでした。私は、開発者体験に焦点を当てて、既存のプロジェクトで✨ が動作す る魔法のようなシミュレータを求めていましたが、Miniflare 1.0についての世間の反応を見ると、それは私だけではなかったようです。
Miniflare 1.0は、瞬時のリロード、ソースマップのサポート(どこでエラーが発生したかがわかる)、すっきりとしたログ({ unknown object }
や大量の JSON スタックトレースはもはや不要)、エラーの原因を強調する美しいエラーページ、ステップスルーデバッガのサポートなどを実現していました。
「幼児語」で動作する可愛らしいエラーページ
次のイテレーション:バージョン2での新機能
7月にMiniflare1.0がリリースされてから比較的短い期間で、プラットフォームとしてのWorkersは劇的に向上しました。Durable Objectsは明示的なトランザクションなしで一貫性を確保するための入・出力ゲートを持ち、Workersは開発者が後方互換性のない修正を選択できる互換性のある日付を持ち、お客様はJavaScriptモジュールを使用してワーカーを制作できるようになりました。
Miniflare 2はこれらの機能をすべてサポートし、3つの主要な設計目標に基づいて完全に再設計されています。
モジュール式: Miniflare 2では、Workersのコンポーネント(KV、Durable Objectsなど)を個別のパッケージ(
@miniflare/kv
、@miniflare/durable-objects
など)に分割し、テスト用に単独でインポートできるようにしています。これにより、R2 Storageのような、リリースされていない新機能のサポートを追加することも容易になります。軽量: Miniflare 1には122個のサードパーティ製パッケージが含まれており、インストールサイズは合計
88.3MB
でした。Miniflare 2は、Node.js 16に含まれる機能を活用することで、これを23個のパッケージと、6MB
のサイズにまで削減しています。正確さ: Miniflare 2では、実際のWorkerのランタイムの癖やスローイングエラーを再現しているので、デプロイする前に破損の予兆を知ることができます。もちろん
wrangler dev
は、実際のデータを使って実際のエッジ環境で実行されているため、常に最も正確なプレビューとなりますが、Miniflare 2は本当に近いものとなっています。
また、新しいlive-reload機能と、Jestによるテストのfirst-classのサポートが追加され、開発者体験がさらに楽しいものになりました。
ローカル開発を始めるにあたって
冒頭で述べたように、Miniflare 2.0は現在wrangler 2.0に統合されているため、完全ローカルのWorker開発サーバーを起動するにはnpx wrangler@beta dev --local
を、Cloudflare Pages Functionsサーバーを起動するにはnpx wrangler@beta pages dev
を実行するだけです。Node.jsの最新リリースがインストールされていることを確認してください。
しかし、Wrangler 1を使用している場合や、お使いのローカル環境をカスタマイズしたい場合は、Miniflare をスタンドアロンでインストールすることができます。もしお客様がwrangler.toml
ファイルを持つ既存のWorkerをお持ちであれば、npx miniflare --live-reload
を実行するだけでライブリロード型の開発サーバーを起動することができます。Miniflareは、KV名前空間やDurable Objectバインディングのような設定をwrangler.toml
ファイルから、そしてsecretsを.env
ファイルから自動的に読み込みます。
Miniflareは高度な設定が可能です。たとえば、再起動の間KVデータを持続させたい場合は、--kv-persist
フラグを含めます。複数のWorkersの実行や HTTPS サーバーの起動など、さらに多くのオプションを利用する場合、Miniflare docsを参照いただくか、npx miniflare --help
を実行してください。
scheduled
状態のイベントハンドラがあれば、お使いのブラウザでhttp://localhost:8787/cdn-cgi/mf/scheduled
にアクセスすることで、手動でトリガすることができます。
JestによるWorkerのテスト
Jestは、最も人気のあるJavaScriptテストフレームワークの1つであるため、ファーストクラスのサポートを追加することは理にかなっています。Miniflare 2.0には、WorkersのランタイムAPIにアクセスできるカスタムテスト環境が含まれています。
例えば、JavaScriptモジュールを使って書かれた次のようなWorkerがあり、各URLの訪問回数をWorkers KVに格納しているとします。
余談ですが、Workers KVは最終的に一貫性を保つ仕組みであるためカウンター用には設計されていません。実際のWorkersではDurable Objectを使うべきです。ここでは簡単な例を紹介します。
...単体テストでは、次のように書くことができます:
// src/index.mjs
export async function increment(namespace, key) {
// Get the current count from KV
const currentValue = await namespace.get(key);
// Increment the count, defaulting it to 0
const newValue = parseInt(currentValue ?? "0") + 1;
// Store and return the new count
await namespace.put(key, newValue.toString());
return newValue;
}
export default {
async fetch(request, env, ctx) {
// Use the pathname for a key
const url = new URL(request.url);
const key = url.pathname;
// Increment the key
const value = await increment(env.COUNTER_NAMESPACE, key);
// Return the new incremented count
return new Response(`count for ${key} is now ${value}`);
},
};
# wrangler.toml
kv_namespaces = [
{ binding = "COUNTER_NAMESPACE", id = "..." }
]
[build.upload]
format = "modules"
dist = "src"
main = "./index.mjs"
...実行には以下を使用しています。
// test/index.spec.mjs
import worker, { increment } from "../src/index.mjs";
// When using `format = "modules"`, bindings are included in the `env` parameter,
// which we don't have access to in tests. Miniflare therefore provides a custom
// global method to access these.
const { COUNTER_NAMESPACE } = getMiniflareBindings();
test("should increment the count", async () => {
// Seed the KV namespace
await COUNTER_NAMESPACE.put("a", "3");
// Perform the increment
const newValue = await increment(COUNTER_NAMESPACE, "a");
const storedValue = await COUNTER_NAMESPACE.get("a");
// Check the return value of increment
expect(newValue).toBe(4);
// Check increment had the side effect of updating KV
expect(storedValue).toBe("4");
});
test("should return new count", async () => {
// Note we're using Worker APIs in our test, without importing anything extra
const request = new Request("http://localhost/a");
const response = await worker.fetch(request, { COUNTER_NAMESPACE });
// Each test gets its own isolated storage environment, so the changes to "a"
// are *undone* automatically. This means at the start of this test, "a"
// wasn't in COUNTER_NAMESPACE, so it defaulted to 0, and the count is now 1.
expect(await response.text()).toBe("count for /a is now 1");
});
// jest.config.js
const { defaults } = require("jest-config");
module.exports = {
testEnvironment: "miniflare", // ✨
// Tell Jest to look for tests in .mjs files too
testMatch: [
"**/__tests__/**/*.?(m)[jt]s?(x)",
"**/?(*.)+(spec|test).?(m)[tj]s?(x)",
],
moduleFileExtensions: ["mjs", ...defaults.moduleFileExtensions],
};
カスタムテスト環境と分離ストレージの詳細についてはMiniflare docs、またはTypeScriptとDurableObjectsを使用したこちらのサンプルプロジェクトを参照してください。
# Install dependencies
$ npm install -D jest jest-environment-miniflare
# Run tests with experimental ES modules support
$ NODE_OPTIONS=--experimental-vm-modules npx jest
Jestをお使いではありませんか?Miniflareでは、vanilla Node.jsやその他のテストフレームワークを使って独自の統合テストを書くことができます。AVAを使用した例については、Miniflareのドキュメントまたはこちらのリポジトリを参照してください。
Miniflareの仕組み
それでは、Miniflareの興味深い機能について詳しく見ていきましょう。
Miniflareは、ChromeのV8 JavaScriptエンジン上に構築されたJavaScriptランタイムであるNode.jsを利用しています。V8はCloudflare Workersランタイムを強化するのと同じエンジンですが、NodeとWorkersはその上に異なるランタイムAPIを実装しています。NodeのAPIは、ユーザーに表示されないようにし、WorkersのAPIを注入するために、MiniflareはNode.jsのvm
モジュールを使用しています。これにより、カスタムV8コンテキストで任意のコードを実行できます。
Workersのコア部分は、Request
とResponse
のクラスです。MiniflareはこれらをNodeチームがNodeにfetch
を導入するために書いたプロジェクトであるundici
から取得しています。また、service workers用にNode 15 で追加されたaddEventListener
とEventTarget
API を使ってイベントをディスパッチする方法も必要です。
それらがあれば、私たちは 小規模の-miniflareを作ることができます。
プラグイン
import vm from "vm";
import { Request, Response } from "undici";
// An instance of this class will become the global scope of our Worker,
// extending EventTarget for addEventListener and dispatchEvent
class ServiceWorkerGlobalScope extends EventTarget {
constructor() {
super();
// Add Worker runtime APIs
this.Request = Request;
this.Response = Response;
// Make sure this is bound correctly when EventTarget methods are called
this.addEventListener = this.addEventListener.bind(this);
this.removeEventListener = this.removeEventListener.bind(this);
this.dispatchEvent = this.dispatchEvent.bind(this);
}
}
// An instance of this class will be passed as the event parameter to "fetch"
// event listeners
class FetchEvent extends Event {
constructor(type, init) {
super(type);
this.request = init.request;
}
respondWith(response) {
this.response = response;
}
}
// Create a V8 context to run user code in
const globalScope = new ServiceWorkerGlobalScope();
const context = vm.createContext(globalScope);
// Example user worker code, this could be loaded from the file system
const workerCode = `
addEventListener("fetch", (event) => {
event.respondWith(new Response("Hello mini-miniflare!"));
})
`;
const script = new vm.Script(workerCode);
// Run the user's code, registering the "fetch" event listener
script.runInContext(context);
// Create an example request, this could come from an incoming HTTP request
const request = new Request("http://localhost:8787/");
const event = new FetchEvent("fetch", { request });
// Dispatch the event and log the response
globalScope.dispatchEvent(event);
console.log(await event.response.text()); // Hello mini-miniflare!
Miniflare monorepoの依存関係グラフ。
WorkersのランタイムAPIはたくさんあるため、上記のようにすべてを手作業で追加・設定するのは面倒です。そこでMiniflare 2では、各パッケージがグローバルやバインディングをエクスポートして、サンドボックスに含めることができるプラグインシステムを採用しました。オプションには、それらのタイプ、CLIフラグ、それらがWrangler設定ファイル内のどこにあるかを説明する注釈があります。
Durable Objects
@Option({
// Define type for runtime validation of the CLI flag
type: OptionType.ARRAY,
// Use --kv instead of auto-generated --kv-namespace for the CLI flag
name: "kv",
// Define -k as an alias
alias: "k",
// Displayed in --help
description: "KV namespace to bind",
// Where to find this option in wrangler.toml
fromWrangler: (config) => config.kv_namespaces?.map(({ binding }) => binding),
})
kvNamespaces?: string[];
入出力ゲートが追加される以前は、通常transaction()
メソッドを使用して一貫性を確保する必要がありました。
Miniflareではこれをoptimistic-concurrency control (OCC)を使って実装しています。しかし、入出力ゲートが利用できるようになったため、新しく書かれたDurable Objectのコードをシミュレーションする際の競合状態を回避するために、Miniflare 2ではそれらを実装する必要がありました。
async function incrementCount() {
let value;
await this.storage.transaction(async (txn) => {
value = await txn.get("count");
await txn.put("count", value + 1);
});
return value;
}
ゲート発表時のブログ記事の説明より:
入力ゲート: ストレージ操作の実行中は、ストレージ完了イベント以外のイベントをオブジェクトに配信してはなりません。その他のイベントは、オブジェクトがJavaScriptコードを実行しなくなり、ストレージ操作の待ちがなくなるまで待機状態にされます。私たちはこれらのイベントが「入力ゲート」が開くのを待っている状態と表現します。
...入力ゲートには2つのメソッドが必要であることがわかります。1つはストレージの動作中にゲートを閉じるもので、1つは入力ゲートが開くまで待つものです:
各Durable Objectには、それぞれに対するInputGate
があります。ストレージの実装ではrunWithClosed
を呼び出して、ストレージ操作が完了するまで他のイベントを待機させます。
class InputGate {
async runWithClosed<T>(closure: () => Promise<T>): Promise<T> {
// 1. Close the input gate
// 2. Run the closure and store the result
// 3. Open the input gate
// 4. Return the result
}
async waitForOpen(): Promise<void> {
// 1. Check if the input gate is open
// 2. If it is, return
// 3. Otherwise, wait until it is
}
}
...次に、別のイベントを配信する準備ができたら、waitForOpen
を呼び出します:
class DurableObjectStorage {
async get<Value>(key: string): Promise<Value | undefined> {
return this.inputGate.runWithClosed(() => {
// Get key from storage
});
}
}
ここで問題に気づいたかもしれません。inputGate
はfetch
内のどこから来るのでしょうか?Worker全体とそのすべてのDurable Objectに対して1つのグローバルスコープしかないため、Durable ObjectのInputGate
ごとにfetch
を行うことはできません。また、それを必要とするすべての関数に別のパラメーターとして渡すようにユーザーに求めることはできません。潜在的に_async
_ 関数間で自動的に渡されるコンテキストの中にそれを格納する何らかの方法が必要です。このために、我々は、 AsyncLocalStorage
クラスを含む、もう一つのあまり知られてNodeモジュールasync_hooks
を使用することができます :
import { fetch as baseFetch } from "undici";
async function fetch(input, init) {
const response = await baseFetch(input, init);
await inputGate.waitForOpen();
return response;
}
また、Durable Objectsには、blockConcurrencyWhile(closure)
メソッドも含まれており、closure
が完了するまでイベントを待機させます。これはまさにrunWithClosed()
のメソッドです。
import { AsyncLocalStorage } from "async_hooks";
const inputGateStorage = new AsyncLocalStorage<InputGate>();
const inputGate = new InputGate();
await inputGateStorage.run(inputGate, async () => {
// This closure will run in an async context with inputGate
await fetch("https://example.com");
});
async function fetch(input: RequestInfo, init: RequestInit): Promise<Response> {
const response = await baseFetch(input, init);
// Get the input gate in the current async context
const inputGate = inputGateStorage.getStore();
await inputGate.waitForOpen();
return response;
}
しかし、今あるものには問題があります。次のようなコードを考えてみましょう。
class DurableObjectState {
// ...
blockConcurrencyWhile<T>(closure: () => Promise<T>): Promise<T> {
return this.inputGate.runWithClosed(closure);
}
}
blockConcurrencyWhile
は入力ゲートを閉じますが、fetch
は入力ゲートが開くまで戻らないためデッドロック状態です!この問題を解決するにはInputGate
を入れ子にする必要があります。
export class CounterObject {
constructor(state: DurableObjectState) {
state.blockConcurrencyWhile(async () => {
const res = await fetch("https://example.com");
this.data = await res.text();
});
}
}
現在、blockConcurrencyWhile
の外側の入力ゲートが閉じられるので、Durable Objectへのフェッチは待機状態にされますが、closureの内側の入力ゲートは開かれるので、fetch
は戻ることができます。
class InputGate {
constructor(private parent?: InputGate) {}
async runWithClosed<T>(closure: () => Promise<T>): Promise<T> {
// 1. Close the input gate, *and any parents*
// 2. *Create a new child input gate with this as its parent*
const childInputGate = new InputGate(this);
// 3. Run the closure, *under the child input gate's context*
// 4. Open the input gate, *and any parents*
// 5. Return the result
}
}
ここではいくつかの詳細を説明していますが、ゲートの実装で追加の構文やコメントをチェックすることができます。?
HTMLRewriter
HTMLRewriter
はHTMLストリームを解析・変換するための新しいクラスです。edge Workers のランタイムでは、Rustライブラリlol-htmlへのCバインディングによって動作しています。幸運なことに、 Ivan Nikulin氏 がこのためにWebAssembly bindingsバインディングを構築したので、Node.jsで同じライブラリを使用することができます。
しかし、これらには書き換え時に外部リソースにアクセスできるasyncハンドラのサポートが欠けていました。
The WebAssembly bindings Rust code includes something like:
class UserElementHandler {
async element(node) {
const response = await fetch("/user");
// ...
}
}
ここで重要なのは、Rustのmove |...| { ... }
クロージャは同期的ですが、ハンドラは非同期的にできることです。これは非async
関数でPromise
をawait
しようとするようなものです。
macro_rules! make_handler {
($handler:ident, $JsArgType:ident, $this:ident) => {
move |arg: &mut _| {
// `js_arg` here is the `node` parameter from above
let js_arg = JsValue::from(arg);
// $handler here is the `element` method from above
match $handler.call1(&$this, &js_arg) {
Ok(res) => {
// Check if this is an async handler
if let Some(promise) = res.dyn_ref::<JsPromise>() {
await_promise(promise);
}
Ok(())
}
Err(e) => ...,
}
}
};
}
これを解決するために、 WebAssemblyモジュールを扱うためのツールのセットである BinaryenのAsyncify機能を使用します。await_promise
を呼び出すたびに、Asyncify は現在の WebAssembly スタックを一時的なストレージに展開します。そして、JavaScriptで、Promise
をawait
します。最後に、一時ストレージからスタックを前の状態に巻き戻し、中断したところから書き換えを続けます。
完全な実装は、 html-rewriter-wasm
パッケージにあります。
Miniflareの今後について
前述の通り、Miniflareはwrangler 2.0に含まれるようになりました。ぜひお試しいただき、ご意見をお聞かせください。
このような素晴らしいプラットフォームと支援してくれるコミュニティを構築してくれたCloudflareのWorkersチームの皆さんに感謝したいと思います。Miniflareに貢献してくださった方、問題を提起してくれた方、提案をしてくださった方、Discordサーバーで質問してくれた方へも感謝をお伝えします。
これで本来のWorkersプロジェクトを終わらせることができるかもしれません...?