AsyncLocalStorage による SSR バインディング
zfb Cloudflare アダプターが AsyncLocalStorage と advanced モードの _worker.js を使ってリクエストごとの env/ctx を SSR ページへ渡す仕組み
概要
SSR フレームワークが Cloudflare Pages 向けにビルドするとき、ひとつ厄介な問題を解決しなければなりません。フレームワークのルーターの奥深くで実行されるページハンドラーが、リクエストごとの env(KV・D1・R2・シークレットのバインディング)と ctx(waitUntil、passThroughOnException)にアクセスする必要がある、という問題です。これらの値はトップレベルの fetch(request, env, ctx) エントリにしか渡されません。グローバルではなく、かつルーターの全レイヤーを手で通して渡していくのは現実的ではありません。
@takazudo/zfb-adapter-cloudflare アダプターはこれを AsyncLocalStorage で解決します。Cloudflare Pages の advanced モード の _worker.js を生成し、それが本体(インナー)のワーカーバンドルをラップして、エントリポイントで { env, ctx, request } を捕捉し、小さな getCloudflareContext<Env>() アクセサ経由でリクエスト内のどこからでも読めるようにします。
このページでは、そのラッパーがどう動くのか、なぜ(プレーンなグローバルではなく)AsyncLocalStorage が正しい仕組みなのか、そして _worker.js が存在した瞬間に引き受けることになる advanced モードの契約について説明します。
アダプターが出力するもの
zfb build は Cloudflare Pages の advanced モード向けに 2 つのファイルを生成します。
dist/_worker.js— 生成されたラッパー(以下に掲載)。dist/_zfb_inner.mjs— 実際の SSR バンドル(フレームワークのルーターとすべてのページ)。
ラッパーはインナーバンドルをインライン化するのではなく、相対パス(./_zfb_inner.mjs)でインポートします。Workerd のモジュールローダーは advanced モードの _worker.js ディレクトリ内で相対 ESM インポートを解決するため、2 つのファイルは並べて配置され、アダプターパッケージは esbuild バイナリを出力に同梱する必要がありません。
生成される _worker.js ラッパー
// AUTO-GENERATED by @takazudo/zfb-adapter-cloudflare. Do not edit.
//
// Cloudflare Pages advanced mode entry. Forwards (request, env, ctx) to
// the inner zfb worker bundle, exposing env/ctx to user code via
// AsyncLocalStorage under a stable globalThis key.
import { AsyncLocalStorage } from "node:async_hooks";
import inner from "./_zfb_inner.mjs";
const STORAGE_KEY = "__zfb_cf_adapter_als__";
function getStorage() {
const g = globalThis;
let als = g[STORAGE_KEY];
if (!als) {
als = new AsyncLocalStorage();
g[STORAGE_KEY] = als;
}
return als;
}
function canDelegateToAssets(env) {
return Boolean(env && env.ASSETS && typeof env.ASSETS.fetch === "function");
}
function isAssetProbeMethod(method) {
// Only safe, side-effect-free methods probe the asset server. POST/
// PUT/PATCH/DELETE go straight to the inner SSR worker.
return method === "GET" || method === "HEAD";
}
export default {
async fetch(request, env, ctx) {
if (isAssetProbeMethod(request.method) && canDelegateToAssets(env)) {
const assetResponse = await env.ASSETS.fetch(request);
if (assetResponse.status !== 404) {
return assetResponse;
}
// Fall through to the inner worker for genuinely dynamic routes.
}
const store = { env, ctx, request };
return getStorage().run(store, () => inner.fetch(request));
},
};
ここでは 2 つのことが起きていて、それらは互いに独立しています。ストレージレジストリ(getStorage + als.run)と、ディスパッチポリシー(isAssetProbeMethod + canDelegateToAssets)です。続く 2 つのセクションで一つずつ見ていきます。
ページからバインディングを読む
SSR ページの内部では、捕捉されたコンテキストをアダプターのアクセサ経由で読みます。
// pages/api/products.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false; // opt out of build-time SSG
interface Env {
ANTHROPIC_API_KEY: string;
DB: D1Database; // a `wrangler.toml` D1 binding named "DB"
}
export default async function Products() {
const { env, ctx } = getCloudflareContext<Env>();
ctx.waitUntil(reportToAnalytics());
// A D1 binding is just-another-object on `env` — query it directly.
const { results } = await env.DB.prepare("SELECT * FROM products").all();
return new Response(JSON.stringify(results), {
headers: { "content-type": "application/json" },
});
}
getCloudflareContext() は、ラッパーが als.run で開いたアクティブな AsyncLocalStorage のストアを読むだけです。<Env> ジェネリックは型のみのもので、env をあなたのバインディング(ここでは DB: D1Database)に絞り込みますが、実行時の値は Cloudflare がラッパーの fetch に渡したまさにその env オブジェクトです。アダプターは env のメンバーを一切検査しないため、env.DB、env.ANTHROPIC_API_KEY、KV ネームスペース、R2 バケット — すべてがそのまま渡されます。D1 データベースは env 上のただのオブジェクトのひとつにすぎません。
なぜグローバルではなく AsyncLocalStorage なのか
これは設計全体で最も重要な点なので、それが回避している失敗モードについて正確に述べておく価値があります。
魅力的な近道は、AsyncLocalStorage を省いて、バインディングをグローバルに書き込んでしまうことです。
// DO NOT DO THIS — it races across concurrent requests.
export default {
async fetch(request, env, ctx) {
globalThis.__env = env; // last writer wins
return inner.fetch(request);
},
};
なぜそれが壊れているのか、正確に説明します。
Cloudflare Workers のアイソレートはシングルスレッドですが、シングルリクエストではありません。多数の進行中リクエストを協調的にインターリーブ(交互実行)します。ハンドラーが await(fetch、D1 クエリ、KV 読み取りといった任意の非同期 I/O)に到達するたびに、それは中断され、イベントループは同じアイソレート内で同じ globalThis を共有しながら別のリクエストのハンドラーを実行できるようになります。
ではグローバルフィールド版で 2 つの並行リクエストを追ってみましょう。
- リクエスト A が到着します。
globalThis.__env = envAを書き込み、その後、遅い D1 クエリをawaitします。 - A が
awaitで中断している間に、イベントループはリクエスト B をディスパッチします。B はglobalThis.__env = envBを書き込み、フィールドを上書きします。 - A のクエリが解決します。A は
awaitの先へ再開し、globalThis.__envを読みます — そしてenvB、つまり自分自身のものではなく B のバインディングを見てしまいます。
これは last-writer-wins(最後に書いた者が勝つ)のデータ競合です。ミューテックスで直せるような並列 CPU の競合ではありません。スレッドは 1 つしかなく、書き込みが命令の途中で衝突することは決してありません。この破損は、実行が await 境界で中断するからこそまさに起こります。グローバルは中断を越えて生き残るため、A が再開後に読む値は、直近のリクエストが書き込んだ何かなのです。負荷がかかると、これはあるテナントのリクエストが別のテナントのシークレットやデータベースハンドルを読む、という形で表面化します — 断続的に、しかもリクエストがほとんど重ならないローカルテストではまず起きない形で。
AsyncLocalStorage はこれを正しいレイヤーで解決します。als.run(store, cb) は cb を根とする非同期継続チェーンに store を束ねます。その run から派生するすべてのコールバック、すべての .then、すべての await 再開は、共有フィールドの「現在」の値ではなく、それ自身がスケジュールされたときにアクティブだったストアを読みます。したがって A が await の後に再開するとき、それはまだ A の run スコープ内にあり envA を読みます。B の並行する run スコープは完全に別個のストアです。各リクエストは自分専用の隔離されたビューを得るため、インターリーブは無害です。
📝 では、なぜ globalThis のキーを使うのか?
ストアのレジストリそのものは、安定したキー __zfb_cf_adapter_als__ の下で globalThis 上に置かれています。これは env をグローバルに保存することとは別物です。ラッパー(_worker.js)とページバンドル(_zfb_inner.mjs)は別々の ESM モジュールグラフなので、一方のモジュールレベルの const als = new AsyncLocalStorage() は、もう一方がインポートするものとは別のインスタンスになってしまいます。単一の AsyncLocalStorage インスタンスを既知のグローバルキーに固定することで、両端がそれを共有できます。リクエストごとのデータは依然としてストアの内部に run でスコープされて存在し、決してグローバルには置かれません。
advanced モードの契約
Pages 出力のルートに _worker.js が存在した瞬間、あなたは advanced モード にオプトインし、ルーティングのルールが変わります。
すべてのリクエストがあなたのワーカーに届きます。あなたのワーカーが明示的に
env.ASSETSへ委譲しない限り、Cloudflare Pages の組み込み静的アセットルーティングは OFF です。
その組み込みルーティングは取るに足らないものではありません。末尾スラッシュの正規化(/docs/foo → 308 → /docs/foo/)を行い、SSG 出力に対してディレクトリを index.html へ解決するレイヤーです。エントリポイントを引き継ぐとき、あなたはそれらの静的ファイルを配信する責任も引き継ぎます。env.ASSETS.fetch(request) がまさにそれです。あなたが今バイパスしたアセットサーバーへのハンドルです。
なぜルーター優先ではなく ASSETS 優先なのか
フレームワークのルーターに GET リクエストを先に処理させ、env.ASSETS はフォールバックとしてのみ使う、というのは魅力的に見えます。やめてください。 事前レンダリング(SSG)されたページに対して、インナールーターは動的に再レンダリングできます — しかしそれはビルド時の head インジェクションなしの HTML を生成します。
zfb build は事前レンダリングされた各 HTML ファイルを後処理し、本番用の <link rel="stylesheet"> と、アイランドのハイドレーションバンドルを読み込む <script type="module"> タグを注入します。その注入はビルドステップであって実行時の関心事ではないため、同じルートを動的 SSR レンダリングすると、注入されていない HTML が出力されます。ページはレンダリングされますが、そのアイランドは決してハイドレートしません — スタイルシートもなく、ハイドレーションスクリプトもありません。env.ASSETS.fetch 経由で事前ビルドされたアセットを配信することが、注入された head を保つ方法です。
ゆえにラッパーのディスパッチポリシーは次のようになっています。
- GET / HEAD リクエストはまず
env.ASSETS.fetch(request)をプローブします。アセットサーバーが404以外の何かを返したら、そのレスポンスが採用されます(事前ビルドされ head 注入済みの HTML、正規化済み)。 - ASSETS から
404が返った場合は、インナーワーカーへフォールスルーします — ここで本当に動的なルート(prerender = false、例:pages/api/*.tsx)が配信されます。 - GET/HEAD 以外のリクエスト(
POST、PUT、PATCH、DELETE)は ASSETS プローブを完全にスキップし、まっすぐインナー SSR ワーカーへ向かいます。Pages のアセットは読み取り専用なので、プローブは常に404/405になります。POST /api/ai-chatのような変更を伴うリクエストは、SSR ハンドラーへ直接届かなければなりません。
nodejs_compat が必須
ラッパーは node:async_hooks から AsyncLocalStorage をインポートします。その Node.js 組み込みは、nodejs_compat 互換性フラグが有効(かつ十分に新しい互換性日付)のときにのみ Workers で利用できます。それがないと、ワーカーは解決できない node:async_hooks インポートでロードに失敗します。フラグの設定方法と互換性日付の選び方については 互換性日付 を参照してください。
どのようにテストされているか
アダプターの受け入れテストは、生成された _worker.js を直接 vitest にインポートし、env.DB がインメモリの D1Database 形状のスタブである合成の Request + env + ctx を構築し、POST を流し(これによりラッパーは ASSETS プローブをバイパスしてまっすぐインナーワーカーへ向かいます)、env.DB.prepare(...).all() を読むページがラッパーの渡した行を見ることを検証します。ラッパーは env をそのまま保存し決して検査しないため、これは「SSR ルートが env.DB に到達できる」というアーキテクチャ上の主張を、ライブの wrangler dev / miniflare 実行なしで証明します。