zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

Worker 上の SSR(アダプターモード)

prerender = false の zfb ページがプロダクションで実際にどう動くか — 2 層のワーカー出力、Workers Static Assets が Worker の実行前にマッチするアセットを配信する仕組み、そして getCloudflareContext() を支える AsyncLocalStorage のトリック。

このページの内容

prerender = false の zfb ページがプロダクションで実際に何を動かすのかというメンタルモデル。2 層のワーカー出力、リクエストが正しいハンドラへどうディスパッチされるか、そして getCloudflareContext() が prop-drilling なしに任意のページコンポーネントへ Cloudflare のバインディングを届ける方法を扱います。

このページはメンタルモデルです。実際のセットアップ(アダプターのインストール、wrangler.toml の配線、D1 へのクエリ、compatibility_flags の設定)については SSR and Cloudflare Bindings を読んでください。 また、このページは zfb が出力する dist/_worker.js に特化したものであり、wrangler で別途デプロイする外部の Worker についてではありません。

このモデルは Workers Static Assets 上で検証されています。Cloudflare Pages のアドバンストモードはこのアダプターについて未検証であるため、以下の Pages との比較はデプロイサポートを主張するものではありません。

あなたのページはただの関数

フレームワークの語彙を取り去れば、prerender = false の zfb ページは Response を返すただの async function です。以下のスニペットでは、renderToStringgetUserupdateProfileAccountLayout はアプリ側のコードを表すプレースホルダーの識別子です。zfb や @takazudo/zfb-adapter-cloudflare のエクスポートではありません。この例で使われている唯一のフレームワーク提供のシンボルは getCloudflareContext です。

// pages/account.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
import AccountLayout from "../layouts/account-layout";

export const prerender = false;

interface Env {
  DB: D1Database;
  SESSION_SECRET: string;
}

export default async function AccountPage(): Promise<Response> {
  const { env, request } = getCloudflareContext<Env>();

  if (request.method === "POST") {
    const form = await request.formData();
    await updateProfile(env, form);
    return new Response(null, { status: 303, headers: { location: "/account" } });
  }

  const user = await getUser(env, request);
  return new Response(
    renderToString(<AccountLayout user={user} />),
    { headers: { "content-type": "text/html" } },
  );
}

この関数は引数を受け取りません。Cloudflare の (request, env, ctx) のタプルは、コールツリーのどこでも(レイアウト、ヘルパー、lib モジュール)getCloudflareContext() 経由で利用できます。同じページ上の <form method="post"> からの POST は同じハンドラに到達します。request.method での分岐がミューテーションの経路です。

ここで起きていることに、手書きの Worker で起きないような特別なことは何もありません。このページの残りでは、getCloudflareContext() が実際にどう動くか、そしてそれを可能にするためにビルドが何を出力するかを説明します。

ビルドが実際に出力するもの

@takazudo/zfb-adapter-cloudflare を設定した状態で zfb build を実行すると、dist/ 配下に 2 つの生成された JavaScript ファイルと、SSR グラフ内の .wasm インポートごとのコンパイル済み Wasm モジュールが生成されます。

dist/_worker.js — アダプターが書き出す小さな自動生成のスタブ。これは Workers Static Assets において wrangler 設定の main が指し示す Worker のエントリです。ルート直下の形は Cloudflare Pages のアドバンストモードの慣習に従いますが、そのターゲットは未検証です。その役割は、(env, ctx, request) のための AsyncLocalStorage スコープをセットアップし、実際に受け取ったリクエストを(env.ASSETS 経由の静的アセットサーバーか内側のバンドルのいずれかへ)ディスパッチすることです。あなたのアプリケーションコードは含みません。

dist/_zfb_inner.mjs — 本物のアプリケーションバンドル。すべての pages/*.tsx ファイル、レイアウト、コンポーネント、lib ヘルパーを単一の Hono 形の ESM モジュールにコンパイルしたものです。これが実際にページをレンダリングします。

コンパイル済み Wasm モジュール

SSR ルートが Wasm をインポートすると、zfb はこれら 2 つの JavaScript ファイルの隣に <name>-<hash>.wasm ファイル(例: index_bg-a1b2c3d4.wasm — これは esbuild 自身の --asset-names=[name]-[hash] の命名規則であり、zfb 固有の仕様ではありません)を出力します。_zfb_inner.mjs はこれをコンパイル済みの WebAssembly.Module としてインポートし、.assetsignore はそれが公開アセットにならないようにします。Satori/Resvg の設定、Wrangler のルール、パッケージサイズの詳細は SSR and Cloudflare Bindings guide にあります。

この 2 ファイル構成は、アダプター内での 2 回目の esbuild パスを回避します。Workerd のモジュールローダーは、デプロイされたワーカーディレクトリ内の相対 ESM インポートを解決するため、_worker.js は単純に import inner from "./_zfb_inner.mjs" でき、両ファイルは独立したままです。

より広い 2 バンドルのメンタルモデル(ワーカーバンドル対 islands バンドル)については Architecture overview を読んでください。

ディスパッチフロー

Workers Static Assets(zfb のデフォルトのデプロイターゲット)では、プラットフォームのアセットルーターがあなたの Worker の 前に 実行されます。run_worker_first = false(zfb のデフォルト)では、Cloudflare は到着した各リクエストをまずビルド済みの静的アセットに照合します。リクエストがプリレンダリング済みのファイルにマッチすれば、エッジがそれを直接配信し、_worker.js は一切実行されません。あなたの Worker にフォールスルーするのは、マッチするアセットがないリクエスト — つまりミスだけです。「すべてのリクエストがまず _worker.js に到達する」という旧来のモデルは反転しています。プラットフォームが先にアセットを配信し、Worker は動的な末端だけを見ます。

アセット層は URL の正規化も担います。html_handling = "auto-trailing-slash" の場合、プリレンダリング済みルートへのリクエストは、ファイルが配信される前に正規の末尾スラッシュ付きの形へリダイレクトされます。

/docs/account307 リダイレクト → /docs/account/dist/docs/account/index.html

(Cloudflare Pages では相当するリダイレクトは 308 です。後述の dual-compat の注記を参照してください。)つまりプロダクションのメンタルモデルはプラットフォーム優先です。静的なヒットはエッジのアセット層が配信し、あなたの Worker は動的な末端 — pages/api/*.tsx のような prerender = false のルートと、アセットルーターが解決しなかったもの — だけを見ます。

内側の env.ASSETS プローブはデッドコードではない

_worker.jsそれ自身も アセットプローブを持っています。内側のバンドルへ委譲する前に、自ら env.ASSETS.fetch(request) を呼ぶことができます。run_worker_first = false では、このプローブはアセットヒットに対しては通常バイパスされます(プラットフォームがすでに配信済みだからです)。すると当然の疑問が湧きます。なぜそれを残すのか? それは、同じ出力ラッパーが、プラットフォームではなく Worker がリクエストの最初の受け口になる状況にも対応しなければならないからです。worker-wrapper.mjs はこれを次のように明記しています。

// 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").

つまりプローブは、次の 3 つのケースで存在意義を持ちます。

  1. Cloudflare Pages(アドバンスドモード)。 Pages には run_worker_first の概念がなく、すべてのリクエストがまず _worker.js に到達します。したがって内側のプローブ こそが アセット配信の経路です。同一の出力ファイルが dual-compat である理由がこれです。

  2. run_worker_first = true アセットルーターより先に Worker を実行するようプラットフォームに指示しているため、すべてのリクエストがプローブに到達します。

  3. run_worker_first = false での、Worker が処理するミス。 プラットフォームのアセットルーターが解決しなかったリクエストは、それでも Worker に到達します。プローブが実行され、env.ASSETS に対して解決できることもあれば、404 を返して内側のバンドルへフォールスルーすることもあります。

プローブが実際に発火するのは、ASSETS バインディングが実際に設定されているときだけです。ラッパーはそれをガードしています。

function canDelegateToAssets(env) {
  return Boolean(env && env.ASSETS && typeof env.ASSETS.fetch === "function");
}

プローブが実行される場合、Worker 内のディスパッチ規則は静的優先・動的が次です。

Request methodDispatch order(Worker が実行される場合)
GET, HEADまず env.ASSETS を探る。内側のワーカーが呼ばれるのは、アセットサーバーが 404 を返した場合だけ。それ以外のステータス(200・307 など)はクライアントに直接返される。
POST, PUT, PATCH, DELETEenv.ASSETS を完全にスキップ。直接内側のワーカーへ進む。

フォールスルーの規則は「404 のみ」であり「200 以外のみ」ではありません。これが重要なのは、アセットサーバーが、プリレンダリングされたルートの末尾スラッシュを正規化するためにリダイレクト(Workers Static Assets では 307、Cloudflare Pages では 308)を出すためです。ラッパーはこれらをクライアントへ届けて、ブラウザがそれを辿って index.html へ到達できるようにしなければなりません。worker-wrapper.mjs を見ると、ラッパーは await env.ASSETS.fetch(request) を呼び、assetResponse.status !== 404 のときはレスポンスを手を加えずに返します。

なぜ GET/HEAD は静的優先なのか: アセットサーバーは上で説明した末尾スラッシュの正規化を担当し、さらに島のハイドレーションを機能させるビルド時の head 注入(<link rel="stylesheet"><script type="module" src="/assets/islands-<hash>.js">)も配信します。もし内側の Hono ルーターがプリレンダリングされたルートを先に処理すると、それらの注入されたアセットなしでリクエスト時に再レンダリングしてしまい、島はハイドレートされません。

なぜ POST/PUT/PATCH/DELETE は直接通すのか: アセットサーバーは定義上リードオンリーです。フォーム送信のためにそれを探っても、常に 404 か 405 を返すだけ — 利益のない不要な往復です。フォーム送信、JSON API 呼び出し、あらゆるミューテーションは直接内側のワーカーへ進みます。

正味の効果はどちらのターゲットでも同じで、静的ページは SSR のコストを払いません。prerender = true のページはアセット層 — Workers Static Assets ではプラットフォームのエッジルーター、Cloudflare Pages では内側の env.ASSETS プローブ — が配信し、内側のワーカーの fetch ハンドラは呼ばれません。(ただし _worker.js がモジュールスコープで内側のバンドルをインポートしているため、内側のバンドルはワーカー起動時に依然としてロードされ評価されます。正確な表現は What is NOT in the worker output を参照してください。)

getCloudflareContext() のトリック

Cloudflare Workers は、同じ V8 アイソレート内で複数のリクエストを並行してディスパッチできます。素朴な globalThis.__env = env への書き込みは、それらの並行リクエストをまたいで競合します。アダプターはこれを node:async_hooksAsyncLocalStorage で解決します。

仕組みは 2 ステップです。

ステップ 1 — ラッパーがコンテキストを保存する。 inner.fetch(request) を呼ぶ前に、生成された _worker.js スタブは次を実行します。

als.run({ env, ctx, request }, () => inner.fetch(request));

これはリクエストごとの async スコープを開きます。そのスコープ内のすべての await(レイアウト、ヘルパー、データベース呼び出しをまたいで)は、依然として同じ保存された値を見ます。

ステップ 2 — ユーザーコードがコンテキストを読む。 getCloudflareContext<Env>() は、同じ AsyncLocalStorage インスタンスに対して als.getStore() を呼びます。アダプターモジュールがそのストレージインスタンスを安定したキーで globalThis に登録するため、ラッパーファイル(_worker.js)とユーザーバンドル(_zfb_inner.mjs)は、別々の ESM モジュールであっても同じインスタンスを共有します。getStore() は、ラッパーがこのリクエストのために保存した { env, ctx, request } オブジェクトを返します。他の並行リクエストのものではありません。

なぜこれが compatibility_flags = ["nodejs_compat"] を必須にするのか。 AsyncLocalStoragenode:async_hooks にあります。Workerd はデフォルトでこれを公開しません。次のように opt-in する必要があります。

# wrangler.toml
compatibility_flags = ["nodejs_compat"]

このフラグがないと、Worker は起動に失敗します。エラーメッセージは欠けているモジュールとして node:async_hooks を名指しします。完全な wrangler.toml 設定については SSR and Cloudflare Bindings guide を参照してください。

なぜ prop-drilling ではなく AsyncLocalStorage なのか。 ページハンドラは共有のレイアウトや lib ヘルパーを自由に組み合わせます。env を明示的なパラメータとして引き回すと、すべてのコンポーネントとヘルパーがそれを受け取ることを強いられます。Cloudflare 固有のインフラと汎用的な UI コードの間の漏れのある結合です。ALS はフレームワークの境界をきれいに保ちます。アダプターがストレージを所有し、ユーザーコードはそれが必要な場所でだけ読みます。

Next.js や Remix を知っているなら

概念は次のようにマッピングされます。

ConceptNext.js App RouterRemixzfb adapter-mode
ファイルベースのルーティングapp/account/page.tsxroutes/account.tsxpages/account.tsx
サーバーでのデータ取得async function Page()loaderasync function AccountPage()
ミューテーションServer Actionsactionプレーンな <form method="post">
レイアウトlayout.tsxネストしたルートレイアウトlayouts/*.tsx(インポート)
静的対動的dynamic = 'force-static' / 'force-dynamic'(常に動的)export const prerender = true / false

仕組みとしては Worker、使い勝手としては App Router スタイルの SSR、思想としては Remix ランタイムなしの Remix。RSC ストリーミングも Server Actions の抽象もなく、ただ 1 つのバンドルと Web の fetch ハンドラがあるだけです。

ワーカー出力に含まれないもの

2 つのカテゴリの出力が、dist/_worker.jsdist/_zfb_inner.mjs から意図的に欠けています。

Islands"use client" でマークされたコンポーネントは、別の esbuild ステップによって 単一の結合された ブラウザ配信バンドルへとコンパイルされます。dev ではこのバンドルは dist/assets/islands.js(安定したファイル名、ハッシュなし)に出力され、プロダクションでは ProductionAssetPipeline がそれを dist/assets/islands-<hash>.js として書き出し、レンダリングされた HTML 内のすべての参照をハッシュ付き URL に書き換えます。このバンドルは dist/_worker.js の一部でも dist/_zfb_inner.mjs の一部でもありません。サーバーで実行される Worker コードではなく、ブラウザ配信の JavaScript です。Worker は静的 HTML のシェルをレンダリングし、ブラウザが islands バンドルをダウンロードして、そのトップレベルのハイドレーションコードがページ上のすべての [data-zfb-island] 要素を走査します。完全な仕組みは Islands を参照してください。

プリレンダリングされたページprerender = true をエクスポートする(またはエクスポートを省略する。デフォルトは true)任意のページは、ビルド時に静的 HTML へレンダリングされます。リクエスト時には、アセット層が .html ファイルを直接配信します — Workers Static Assets(run_worker_first = false)ではプラットフォームのエッジアセットルーターが、Cloudflare Pages では内側の env.ASSETS プローブが配信します — そのため、それらのルートに対して内側の Worker の fetch ハンドラは 呼ばれません

内側のバンドルがそもそも ロードされる かどうかは、Worker が起動するかどうかに依存します。_worker.js が静的な import inner from "./_zfb_inner.mjs" を行うため、いったん Worker が起動すれば、どのルートがその起動を引き起こしたかに関係なく、workerd は内側のモジュールグラフ全体をメモリに引き込みます。静的ヒットは inner.fetch() をスキップしますが、workerd が内側のバンドルのロードをスキップするわけではありません。Cloudflare Pages(および run_worker_first = true)では、Worker は最初のリクエストで起動するため、これは常に当てはまります。一方、Workers Static Assets の run_worker_first = false では、Worker が起動するのはリクエストが初めてアセットルーターをミスしたときだけです。トラフィックがプリレンダリング済みのアセットにしか当たらないデプロイでは、Worker がまったく起動せず — したがって内側のバンドルもロードされない — ということもあり得ます。

関連

  • Architecture overview — 2 バンドルのモデルと、ワーカーバンドルの形がビルド時とプロダクションの間の安定したコントラクトであること。

  • Islands"use client" がコンポーネントを Worker の外に存在するブラウザ配信バンドルへどう opt-in させるか。

  • SSR and Cloudflare Bindings — 実際のセットアップ向け: wrangler.toml、D1 クエリ、シークレット、wrangler dev でのローカル開発。

Revision History

作成更新