Miniflare 2.0: fully-local development and testing for Workers

2021年7月、私はWorkers向けの遊び心を持ちフル機能を備えた完全ローカルシミュレータであるMiniflare 1.0を、Cloudflare Workers Discordサーバ上に立ち上げました。cloudflare-worker-localプロジェクトへのプルリクエストとして始まったものが今ではCloudflareの公式プロジェクトとなり、Workersのエコシステムの中核となってwrangler 2.0に統合されています。本日、次のメジャーバージョンである、よりモジュール化された、軽量で正確なMiniflare 2.0のリリースを発表できることを嬉しく思います。🔥

背景:Miniflareが作られた理由

image3-7

2020年末頃に、私は初めてとなるWorkersアプリの制作を始めました。最初は、当時最新のリリースである wrangler devを使用していましたが、これは変更が反映されるまでに数秒かかることがわかりました。Workersのランタイム上で動作していることを考えると十分凄いことではありますが、私はフロントエンドの開発にViteを使用していたので、開発者体験の大幅な高速化が可能であることを知っていました。

その後、ローカルのWorkersシミュレータであるcloudflare-worker-localcloudworkerに目を付けましたが、Workers Sitesのような新しい機能はサポートされていませんでした。私は、開発者体験に焦点を当てて、既存のプロジェクトで✨ が動作す る魔法のようなシミュレータを求めていましたが、Miniflare 1.0についての世間の反応を見ると、それは私だけではなかったようです。

Miniflare 1.0は、瞬時のリロード、ソースマップのサポート(どこでエラーが発生したかがわかる)、すっきりとしたログ({ unknown object }や大量の JSON スタックトレースはもはや不要)、エラーの原因を強調する美しいエラーページ、ステップスルーデバッガのサポートなどを実現していました。

image2-81

「幼児語」で動作する可愛らしいエラーページ

image5-5

次のイテレーション:バージョン2での新機能

7月にMiniflare1.0がリリースされてから比較的短い期間で、プラットフォームとしてのWorkersは劇的に向上しました。Durable Objectsは明示的なトランザクションなしで一貫性を確保するための入・出力ゲートを持ち、Workersは開発者が後方互換性のない修正を選択できる互換性のある日付を持ち、お客様はJavaScriptモジュールを使用してワーカーを制作できるようになりました。

Miniflare 2はこれらの機能をすべてサポートし、3つの主要な設計目標に基づいて完全に再設計されています。

  1. モジュール式: Miniflare 2では、Workersのコンポーネント(KV、Durable Objectsなど)を個別のパッケージ(@miniflare/kv@miniflare/durable-objectsなど)に分割し、テスト用に単独でインポートできるようにしています。これにより、R2 Storageのような、リリースされていない新機能のサポートを追加することも容易になります。
  2. 軽量: Miniflare 1には122個のサードパーティ製パッケージが含まれており、インストールサイズは合計88.3MBでした。Miniflare 2は、Node.js 16に含まれる機能を活用することで、これを23個のパッケージと、6MB のサイズにまで削減しています。
  3. 正確さ: Miniflare 2では、実際のWorkerのランタイムの癖やスローイングエラーを再現しているので、デプロイする前に破損の予兆を知ることができます。もちろんwrangler devは、実際のデータを使って実際のエッジ環境で実行されているため、常に最も正確なプレビューとなりますが、Miniflare 2は本当に近いものとなっています。

また、新しいlive-reload機能と、Jestによるテストのfirst-classのサポートが追加され、開発者体験がさらに楽しいものになりました。

ローカル開発を始めるにあたって

冒頭で述べたように、Miniflare 2.0は現在wrangler 2.0に統合されているため、完全ローカルのWorker開発サーバーを起動するにはnpx [email protected] dev --localを、Cloudflare Pages Functionsサーバーを起動するにはnpx [email protected] 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],
};

...実行には以下を使用しています。

# Install dependencies
$ npm install -D jest jest-environment-miniflare
# Run tests with experimental ES modules support
$ NODE_OPTIONS=--experimental-vm-modules npx jest

カスタムテスト環境と分離ストレージの詳細についてはMiniflare docs、またはTypeScriptとDurableObjectsを使用したこちらのサンプルプロジェクトを参照してください。

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のコア部分は、RequestResponseのクラスです。MiniflareはこれらをNodeチームがNodeにfetchを導入するために書いたプロジェクトであるundiciから取得しています。また、service workers用にNode 15 で追加されたaddEventListenerEventTarget 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!

プラグイン

image1-108

Miniflare monorepoの依存関係グラフ。

WorkersのランタイムAPIはたくさんあるため、上記のようにすべてを手作業で追加・設定するのは面倒です。そこでMiniflare 2では、各パッケージがグローバルやバインディングをエクスポートして、サンドボックスに含めることができるプラグインシステムを採用しました。オプションには、それらのタイプ、CLIフラグ、それらがWrangler設定ファイル内のどこにあるかを説明する注釈があります。

@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[];

Durable Objects

入出力ゲートが追加される以前は、通常transaction()メソッドを使用して一貫性を確保する必要がありました。

async function incrementCount() {
  let value;
  await this.storage.transaction(async (txn) => {
    value = await txn.get("count");
    await txn.put("count", value + 1);
  });
  return value;
}

Miniflareではこれをoptimistic-concurrency control (OCC)を使って実装しています。しかし、入出力ゲートが利用できるようになったため、新しく書かれたDurable Objectのコードをシミュレーションする際の競合状態を回避するために、Miniflare 2ではそれらを実装する必要がありました。

ゲート発表時のブログ記事の説明より:

入力ゲート: ストレージ操作の実行中は、ストレージ完了イベント以外のイベントをオブジェクトに配信してはなりません。その他のイベントは、オブジェクトがJavaScriptコードを実行しなくなり、ストレージ操作の待ちがなくなるまで待機状態にされます。私たちはこれらのイベントが「入力ゲート」が開くのを待っている状態と表現します。

...入力ゲートには2つのメソッドが必要であることがわかります。1つはストレージの動作中にゲートを閉じるもので、1つは入力ゲートが開くまで待つものです:

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
  }
}

各Durable Objectには、それぞれに対するInputGateがあります。ストレージの実装ではrunWithClosedを呼び出して、ストレージ操作が完了するまで他のイベントを待機させます。

class DurableObjectStorage {
  async get<Value>(key: string): Promise<Value | undefined> {
    return this.inputGate.runWithClosed(() => {
      // Get key from storage
    });
  }
}

...次に、別のイベントを配信する準備ができたら、waitForOpenを呼び出します:

import { fetch as baseFetch } from "undici";

async function fetch(input, init) {
  const response = await baseFetch(input, init);
  await inputGate.waitForOpen();
  return response;
}

ここで問題に気づいたかもしれません。inputGatefetch内のどこから来るのでしょうか?Worker全体とそのすべてのDurable Objectに対して1つのグローバルスコープしかないため、Durable ObjectのInputGateごとにfetchを行うことはできません。また、それを必要とするすべての関数に別のパラメーターとして渡すようにユーザーに求めることはできません。潜在的に_async_ 関数間で自動的に渡されるコンテキストの中にそれを格納する何らかの方法が必要です。このために、我々は、 AsyncLocalStorage クラスを含む、もう一つのあまり知られてNodeモジュールasync_hooksを使用することができます :

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;
}

また、Durable Objectsには、blockConcurrencyWhile(closure)メソッドも含まれており、closureが完了するまでイベントを待機させます。これはまさにrunWithClosed()のメソッドです。

class DurableObjectState {
  // ...

  blockConcurrencyWhile<T>(closure: () => Promise<T>): Promise<T> {
    return this.inputGate.runWithClosed(closure);
  }
}

しかし、今あるものには問題があります。次のようなコードを考えてみましょう。

export class CounterObject {
  constructor(state: DurableObjectState) {
    state.blockConcurrencyWhile(async () => {
      const res = await fetch("https://example.com");
      this.data = await res.text();
    });
  }
}

blockConcurrencyWhileは入力ゲートを閉じますが、fetchは入力ゲートが開くまで戻らないためデッドロック状態です!この問題を解決するにはInputGateを入れ子にする必要があります。

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
  }
}

現在、blockConcurrencyWhileの外側の入力ゲートが閉じられるので、Durable Objectへのフェッチは待機状態にされますが、closureの内側の入力ゲートは開かれるので、fetchは戻ることができます。

ここではいくつかの詳細を説明していますが、ゲートの実装で追加の構文やコメントをチェックすることができます。🙂

HTMLRewriter

HTMLRewriterはHTMLストリームを解析・変換するための新しいクラスです。edge Workers のランタイムでは、Rustライブラリlol-htmlへのCバインディングによって動作しています。幸運なことに、 Ivan Nikulin氏 がこのためにWebAssembly bindingsバインディングを構築したので、Node.jsで同じライブラリを使用することができます。

しかし、これらには書き換え時に外部リソースにアクセスできるasyncハンドラのサポートが欠けていました。

class UserElementHandler {
  async element(node) {
    const response = await fetch("/user");
    // ...
  }
}

The WebAssembly bindings Rust code includes something like:

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) => ...,
      }
    }
  };
}

ここで重要なのは、Rustのmove |...| { ... }クロージャは同期的ですが、ハンドラは非同期的にできることです。これは非async関数でPromiseawaitしようとするようなものです。

これを解決するために、 WebAssemblyモジュールを扱うためのツールのセットである BinaryenAsyncify機能を使用します。await_promiseを呼び出すたびに、Asyncify は現在の WebAssembly スタックを一時的なストレージに展開します。そして、JavaScriptで、Promiseawaitします。最後に、一時ストレージからスタックを前の状態に巻き戻し、中断したところから書き換えを続けます。

完全な実装は、 html-rewriter-wasm パッケージにあります。

image4-4

Miniflareの今後について

前述の通り、Miniflareはwrangler 2.0に含まれるようになりました。ぜひお試しいただき、ご意見をお聞かせください。

このような素晴らしいプラットフォームと支援してくれるコミュニティを構築してくれたCloudflareのWorkersチームの皆さんに感謝したいと思います。Miniflareに貢献してくださった方、問題を提起してくれた方、提案をしてくださった方、Discordサーバーで質問してくれた方へも感謝をお伝えします。

これで本来のWorkersプロジェクトを終わらせることができるかもしれません...😅