AsyncLocalStorage による SSR バインディング
zfb Cloudflare アダプターが AsyncLocalStorage と生成される _worker.js ラッパーを使って、Workers Static Assets または Pages advanced モードでリクエストごとの env/ctx を SSR ページへ渡す仕組み
概要
SSR フレームワークが Cloudflare 向けにビルドするとき、ひとつ厄介な問題を解決しなければなりません。フレームワークのルーターの奥深くで実行されるページハンドラーが、リクエストごとの env(KV・D1・R2・シークレットのバインディング)と ctx(waitUntil、passThroughOnException)にアクセスする必要がある、という問題です。これらの値はトップレベルの fetch(request, env, ctx) エントリにしか渡されません。グローバルではなく、かつルーターの全レイヤーを手で通して渡していくのは現実的ではありません。
アダプターはこれを AsyncLocalStorage で解決します。_worker.js を生成し、それが本体(インナー)のワーカーバンドルをラップして、エントリポイントで { env, ctx, request } を捕捉し、小さな getCloudflareContext<Env>() アクセサ経由でリクエスト内のどこからでも読めるようにします。生成されたラッパーは 2 通りにデプロイできます。Workers Static Assets の [assets] ブロックと並べて wrangler.toml の main エントリとして使う方法(アダプターが主に対象とし、検証もされている方法)と、Cloudflare Pages advanced モード の _worker.js として使う方法(同じファイル・同じ規約ですが、アダプター自身のテストスイートでは検証されていません)です。
このページでは、そのラッパーがどう動くのか、なぜ(プレーンなグローバルではなく)AsyncLocalStorage が正しい仕組みなのか、そして _worker.js が存在した瞬間に引き受けることになるリクエストディスパッチの契約について — どちらのデプロイ先であっても — 説明します。
アダプターが出力するもの
zfb build はアダプターのワーカー出力向けに 2 つのファイルを生成します。
dist/— 生成されたラッパー(以下に掲載)。_ worker. js dist/— 実際の SSR バンドル(フレームワークのルーターとすべてのページ)。_ zfb_ inner. mjs
この 2 つのファイルはデプロイ先が変わっても同じです。Workers Static Assets では、_worker.js は wrangler.toml の main エントリになり、同じ dist/ ディレクトリを指す [assets] ブロックと並べて配置されます。Cloudflare Pages advanced モード では、同じ _worker.js が Pages 出力のルートに置かれ、規約によって認識されます — wrangler.toml の main/[assets] の配線は不要ですが、アダプターのテストスイートが検証している対象でもありません。
ラッパーはインナーバンドルをインライン化するのではなく、相対パス(.)でインポートします。Workerd のモジュールローダーは _worker.js ディレクトリ内で相対 ESM インポートを解決するため、2 つのファイルは並べて配置され、アダプターパッケージは esbuild バイナリを出力に同梱する必要がありません。
生成される _worker.js ラッパー
// AUTO-GENERATED by @takazudo/zfb-adapter-cloudflare. Do not edit.
//
// Cloudflare Workers Static Assets entry (`main` in wrangler.toml,
// alongside an `[assets]` block; also deployable to Cloudflare Pages
// advanced mode). Forwards (request, env, ctx) to the inner zfb worker
// bundle, exposing env/ctx to user code via AsyncLocalStorage under a
// stable globalThis key.
//
// The same key is read by @takazudo/zfb-adapter-cloudflare's
// getCloudflareContext() inside the user bundle, so the two ends share
// state even though they live in separate ESM module instances.
//
// Dispatch order is deliberately "ASSETS first, inner on 404" — this
// holds across both deploy targets, but *why* the ASSETS probe fires
// differs:
//
// - Workers Static Assets with `run_worker_first = false` (the zfb
// default): the platform itself serves asset hits before the
// Worker ever runs, so for those requests this in-Worker probe is
// normally bypassed — it never had a chance to run. The probe below
// is NOT dead code: it is still exercised for Pages (no
// `run_worker_first` concept; every request hits the Worker), for
// `run_worker_first = true` deployments, and for any request the
// platform's asset router itself does not resolve (which still
// reaches this Worker as a "miss").
// - GET/HEAD requests probe env.ASSETS first. The asset server
// handles the trailing-slash canonicalisation for SSG output (e.g.
// "/docs/foo" → redirect → "/docs/foo/" → dist/docs/foo/index.html
// — 307 on Workers Static Assets, 308 on Cloudflare Pages), so
// prerendered routes get the build-time head-injected HTML
// (<link rel="stylesheet">, <script type="module" src="/assets/
// islands-…">). If we let the inner Hono router handle them first,
// it would dynamic-SSR the page WITHOUT the prod head injection
// (which is a build-time post-process, not a runtime concern), and
// islands would never hydrate.
// - On 404 from ASSETS, fall through to the inner zfb worker — this
// is where genuinely dynamic routes (`prerender = false`, e.g.
// `pages/api/*.tsx`) are served. EXCEPTION: when the asset 404
// carries a *styled* 404-page body (`not_found_handling = "404-page"`
// on Workers Static Assets, or the `404.html` convention on Pages —
// detected as an HTML document by content-type; the assets binding
// streams every response and never sends a `content-length` header)
// AND the inner ALSO 404s with only the framework default body (Hono's
// default not-found, `text/plain` "404 Not Found", or a bare 404
// with no content-type), the earlier styled asset 404 is preferred
// over the inner's plain 404 — otherwise the styled `404.html` would
// be discarded and users would see the inner plain-text 404 (issue
// #1322). Any inner 404 that declares another content-type is a
// deliberate response and WINS: a `text/html` 404 is a rendered
// custom not-found page (a `prerender = false` route SSRing its own
// 404), and an `application/json` 404 is an intentional API error —
// both are returned unchanged. Under `not_found_handling = "none"`
// the asset 404 body is empty/non-HTML, so the inner always wins —
// the historical behavior is preserved.
// - Non-GET/HEAD requests skip ASSETS and go straight to the inner —
// assets are read-only by definition, and we want POSTs
// (`/api/ai-chat`, etc.) to reach the SSR handler without a probe
// that would always 405 / 404.
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";
}
function assetHasStyled404Body(response) {
// True iff an ASSETS 404 carries the site's *styled* 404 page.
// Platform fact: `not_found_handling = "404-page"` (Workers Static
// Assets) and the Pages `404.html` convention both serve that page as
// an HTML document — auto-detected `content-type: text/html`. The
// Workers Static Assets binding streams every response (chunked
// locally, HTTP/2 framed in production) and never sends a
// `content-length` header, so content-type alone is the discriminator.
// `not_found_handling = "none"` sends Cloudflare's default 404 with
// NO headers at all (empty/non-HTML content-type), so this still
// returns false there and the inner wins.
// Header-only (never reads the body) so it holds for HEAD too and leaves
// the one-shot asset stream intact for a verbatim return.
const contentType = (response.headers.get("content-type") || "").toLowerCase();
return contentType.includes("text/html");
}
function innerIsFrameworkDefault404(response) {
// True iff an inner 404 is the framework's *generic* not-found, which
// yields to the styled asset 404 page. The inner zfb router never
// overrides Hono's default handler, so a route miss is always Hono's
// default not-found — `text/plain` "404 Not Found" (or a bare 404 with no
// content-type). Any inner 404 that declares another content-type is a
// *deliberate* response and must WIN over the static styled asset page: a
// `text/html` 404 is a rendered custom not-found page (e.g. a
// `prerender = false` [slug] route SSRing its own 404), and an
// `application/json` 404 is an intentional machine-readable API error.
// Trade-off: a bare `text/plain` API 404 is indistinguishable from the
// framework default and will yield to the styled page — API errors should
// use `application/json` to be preserved.
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (contentType === "") return true;
return contentType.includes("text/plain");
}
export default {
async fetch(request, env, ctx) {
if (isAssetProbeMethod(request.method) && canDelegateToAssets(env)) {
// Trade-off: every GET/HEAD request that matches a prerendered route pays
// the cost of one env.ASSETS.fetch() round-trip before the inner worker
// sees it (when this probe actually runs — see the header comment on
// run_worker_first). The upside is that the asset server handles
// trailing-slash canonicalisation (e.g. /docs/foo → redirect → /docs/foo/)
// and serves the build-time head-injected HTML with the hashed
// <link>/<script> tags. If we skipped this probe, prerendered routes
// would be dynamic-SSR'd by the inner Hono router without the prod head
// injection, and islands would never hydrate. For purely dynamic apps
// (prerender=false everywhere) the extra round-trip is pure overhead;
// splitting the wrapper into two variants is the accepted future escape
// hatch for that case.
const assetResponse = await env.ASSETS.fetch(request);
if (assetResponse.status !== 404) {
return assetResponse;
}
// Asset 404. Fall through to the inner worker for genuinely dynamic
// routes, but first hold the asset response UNREAD if it carries a
// styled 404 page: if the inner also 404s with only the framework
// default body (text/plain or none), we return this styled page
// instead of the inner's plain 404 (issue #1322). An inner 404 that
// renders its own page (text/html) or a structured API error
// (application/json) wins. Returned verbatim — the one-shot body is
// untouched.
const styledAsset404 = assetHasStyled404Body(assetResponse) ? assetResponse : null;
const store = { env, ctx, request };
const innerResponse = await getStorage().run(store, () => inner.fetch(request));
if (
styledAsset404 !== null &&
innerResponse.status === 404 &&
innerIsFrameworkDefault404(innerResponse)
) {
return styledAsset404;
}
return innerResponse;
}
const store = { env, ctx, request };
return getStorage().run(store, () => inner.fetch(request));
},
};ここでは 3 つのことが起きていて、最初の 2 つは 3 つ目とは独立しています。ストレージレジストリ(getStorage + als.run)、ディスパッチポリシー(isAssetProbeMethod + canDelegateToAssets)、そして訪問者が実際に目にする 404 の中身を決める404 の裁定(assetHasStyled404Body + innerIsFrameworkDefault404)です。続くいくつかのセクションで順番に見ていきます。
ページからバインディングを読む
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 上のただのオブジェクトのひとつにすぎません。
env はジェネリックで絞り込まれるが、ctx は絞り込まれない
getCloudflareContext<Env>() の Env ジェネリックは env の型だけを広げるものであり、ctx には影響しません。パッケージは ctx の型を、waitUntil と passThroughOnException の 2 つのメソッドだけを持つ、パッケージ独自の最小限の CloudflareExecutionContext インターフェースとして定義しています。これは意図的なものです — アダプターは型レベルで @cloudflare/workers-types に依存していません(依存すると、このパッケージを使うすべての利用者にそのインストールを強制することになります)。そのため、実際にスレッドして渡す形だけを公開しています。
実行時の値は影響を受けません。ctx は依然として Cloudflare がラッパーの fetch に渡したまさにその ExecutionContext オブジェクトであり、何も取り除かれていません。狭められているのはコンパイル時の型だけです。自分のコードでより広い @cloudflare/workers-types の ExecutionContext の形(たとえば呼び出しているライブラリがそれを要求する場合など)が必要なら、呼び出し側で自分でキャストして広げてください。
const { ctx } = getCloudflareContext<Env>();
const typedCtx = ctx as ExecutionContext; // safe: same underlying objectここにジェネリックパラメータが存在しないのは、env と違って ctx の形はプロジェクト固有ではなく、どの利用者に対しても同じ 2 つのメソッドだからです。より広い型が必要な場合は、呼び出し側での一度きりのキャストで十分です。
なぜグローバルではなく 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 でスコープされて存在し、決してグローバルには置かれません。
リクエストディスパッチの契約
_worker.js が存在した瞬間、あなたは本来なら静的ファイルとして配信されるはずだったリクエストへの責任を引き継ぎます。その責任がどこまで、いつ及ぶかは、デプロイ先によって異なります。
Cloudflare Pages advanced モード
すべてのリクエストがあなたのワーカーに届きます。あなたのワーカーが明示的に
env.ASSETSへ委譲しない限り、Cloudflare Pages の組み込み静的アセットルーティングは OFF です。
その組み込みルーティングは取るに足らないものではありません。末尾スラッシュの正規化(/ → 308 → /)を行い、SSG 出力に対してディレクトリを index.html へ解決するレイヤーです。エントリポイントを引き継ぐとき、あなたはそれらの静的ファイルを配信する責任も引き継ぎます。env.ASSETS.fetch(request) がまさにそれです。あなたが今バイパスしたアセットサーバーへのハンドルです。
Workers Static Assets と run_worker_first
Workers Static Assets には、Pages にはない設定項目があります。wrangler.toml の [assets] ブロック内の run_worker_first です。
run_worker_first = false(zfb のデフォルト — このページの主張の検証に使ったzfb-example-*リファレンスアプリのほとんどはこのキーを完全に省略しており、それはプラットフォームのデフォルトであるfalseに解決されます。1 つのアプリは理由を説明するコメント付きで明示的にfalseを繰り返しており、もう 1 つのアプリは意図的にtrueを選んでいます): プラットフォーム自身のアセットルーターが、Pages と同じ正規化(ここでは307、Pages では308)で、あなたのワーカーが実行される前に 一致する GET/HEAD リクエストを配信します。あなたのワーカーは、プラットフォームのルーターが解決できなかった場合 — 本当の意味でのミスの場合にのみ、そのリクエストを目にします。run_worker_first = true: すべてのリクエストがまずあなたのワーカーに届きます。上記の Pages advanced モードと同じ契約です。
いずれの場合も、ラッパー内のディスパッチポリシー(isAssetProbeMethod + canDelegateToAssets)は変わらずラッパーの一部です — それが正確にいつ実行され、いつプラットフォームに先を越されるのかについては、後述の「run_worker_first = false 下でのプラットフォームレベルのプローブバイパス」を参照してください。
なぜルーター優先ではなく 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が返った場合、ラッパーは無条件にフォールスルーするわけではありません — 訪問者が実際にどちらの 404 の中身を目にすることになるかは、後述の「404 の裁定」を参照してください。GET/HEAD 以外のリクエスト(
POST、PUT、PATCH、DELETE)は ASSETS プローブを完全にスキップし、まっすぐインナー SSR ワーカーへ向かいます。アセットは読み取り専用なので、プローブは常に404/405になります。POST /api/ai-chatのような変更を伴うリクエストは、SSR ハンドラーへ直接届かなければなりません。
run_worker_first = false 下でのプラットフォームレベルのプローブバイパス
zfb のデフォルトである Workers Static Assets では、ほとんどの静的アセットへのアクセスはラッパーの isAssetProbeMethod + canDelegateToAssets のチェックにまったく到達しません — プラットフォーム自身のアセットルーターがすでにレスポンスを返しており、そのリクエストに対してワーカーは一度も呼び出されていないのです。これはインナーのプローブが死んだコードになっているわけではありません。次の場合には依然として実行されます。
デプロイ先が Cloudflare Pages advanced モード の場合 — Pages には
run_worker_firstという概念自体がないため、すべてのリクエストがワーカーに届き、インナーのプローブが唯一のディスパッチレイヤーになります。Workers Static Assets で
run_worker_first = trueが設定されている場合 — Pages と同様、すべてのリクエストがまずワーカーに届きます。run_worker_first = falseであっても、プラットフォームのアセットルーター自体がリクエストを解決できない場合 — その「ミス」はワーカーに届き、そこでenv.ASSETS.fetch(request)が再度試され、その404を受けて、上で説明したとおりインナー SSR ワーカーへフォールスルーします。
要するに、zfb のデフォルトでは、プラットフォームが(ビルド済みアセットに一致するリクエストという)よくあるケースを黙って処理し、ラッパー自身のプローブロジックはプラットフォームのルーターがカバーしないケースのために存在します — そして Pages では、そもそもプラットフォームレベルの近道が一切存在しないため、ルーティング全体がそれに該当します。
404 の裁定: スタイル付きページ vs 意図的なレスポンス
not_found_handling = "404-page"(Workers Static Assets)や、ビルドのルートにある dist/(Pages の同等の規約)を使うと、一致しないパスは Cloudflare の素の既定 404 の代わりに、あなたのスタイル付き 404 ページを配信します。しかし prerender = false のルート(pages/api/*.tsx ハンドラーのような)は、あなた自身の SSR コードの内部から意図的に 404 を返すこともあります — そしてラッパーの ASSETS 優先プローブによって、同じリクエストに対してスタイル付きページとインナーワーカー自身の 404 の両方が候補になります。ラッパーはこの 2 つをコンテンツタイプに基づく裁定で選び分け、インナーの 404 が取りうる形をちょうど 3 種類認識します。
インナー 404 の content-type | 解釈 | 勝者 |
|---|---|---|
| (空 — ヘッダーなし) | 素の 404、ルートハンドラーは実行されていない | スタイル付きアセット 404 |
text/plain | Hono 自身の既定の not-found 本文 | スタイル付きアセット 404 |
text/html | あなたの SSR コードがレンダリングした、意図的なカスタム 404 ページ | インナーのレスポンス |
application/json | 意図的な、機械可読な API エラー | インナーのレスポンス |
最初の 2 行はまとめて「フレームワークの既定」(innerIsFrameworkDefault404)です。zfb のルーターは Hono の組み込み not-found ハンドラーを一切上書きしないため、インナーワーカー内での本当のルートミスは、あなたのコードが何も実行されないまま、この 2 つの形のどちらかを生成します。assetHasStyled404Body はもう一方の判断 — そもそも ASSETS の 404 がそちらを優先する価値のあるスタイル付きページ(content-type: text/html)を持っているかどうか — を担います。not_found_handling = "none" では、Cloudflare の素のフォールバック 404 にはヘッダーが一切なく、assetHasStyled404Body は false を返し、インナーのレスポンスが常に勝ちます — この設定については、裁定導入以前からの挙動がそのまま保たれています。
どちらのチェックもヘッダーのみを見るもので、どちらの関数もレスポンスボディを読みません。これが重要なのは、Workers Static Assets のバインディングがすべてのレスポンスをストリームで返し、content-length ヘッダーを一切送らないためです。したがって判断の前に得られる唯一の手がかりはコンテンツタイプであり、そして勝ったレスポンスは one-shot のストリームをそのまま返せる必要があります。裁定に負けたほうのレスポンスもやはり読まれません -- そのボディは単に消費されないまま残り、負けた Response がスコープを外れた時点で、他の参照されなくなったオブジェクトと同じようにランタイムが回収します。明示的な後始末は必要ありません。
罠: content-type を明示しない JSON API の 404 は静かに上書きされる
application/json のインナー 404 が勝つのは、ハンドラーが実際にそのヘッダーを設定している場合だけです。prerender = false の API ルートが素の Response(null, { status: 404 }) で 404 を返したり、Hono の既定の not-found を未処理のまま通過させたりすると、フレームワークの既定(空または text/plain の content-type)とまったく同じ形になります — 裁定ロジックはそれを本当のルートミスと区別できません。結果としてスタイル付きの dist/ が静かに勝ち、JSON を期待している API 呼び出し元は HTML ページを受け取ることになります。自分自身の 404 を保ちたいルートは — API であれ何であれ — 返すすべての 404 に明示的な content-type を設定しなければなりません。API エラーなら application/json、カスタムのレンダリングされた not-found ページなら text/html です。
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 実行なしで証明します。