SSR と Cloudflare バインディング
Cloudflare アダプターで動的ルートを配信し、Worker バインディング(シークレット・KV・D1 データベース)を SSR ハンドラ内から読み取る。
このページの内容
ルートをビルド時の静的レンダリングから除外し、@takazudo/zfb-adapter-cloudflare を使って Static Assets を備えた Cloudflare Worker としてデプロイし、ルートの SSR ハンドラ内から Cloudflare Worker バインディング(シークレット、環境変数、D1 データベース)を読み取る方法。
2 種類の Worker — まず区別する
zfb + Cloudflare のプロジェクトでは、どちらも「Worker」と呼ばれる 2 つの異なる概念があります。これらを混同することが、最もよくある混乱の原因です。
zfb が生成する dist/ — prerender = false をエクスポートするすべてのルートに対して Cloudflare アダプターが生成します。これは静的ページと同じ TSX パイプラインの中で動作します。共有レイアウト、コンポーネント、MDX 仮想モジュールはすべて、SSG ルートとまったく同じように機能します。
外部の単独 Worker — 別途デプロイされる、あなた自身の wrangler でビルドした Worker バンドル(認証 Worker、写真アップロード Worker、決済 Webhook Worker など)です。zfb はこれらをまったく認識しません。zfb と外部 Worker の継ぎ目は常に HTTP/JSON です。外部 Worker を fetch() する pages/api/*.tsx のプロキシルートか、直接 fetch() を呼ぶ prerender = false ページのどちらかになります。
これが重要な理由: AI エージェントや人間の読者は、共有レイアウトの TSX を外部 Worker に直接インポートしようとしがちです。それは機能しません。外部 Worker は別のバンドラ、別のランタイムを持ち、zfb の仮想モジュールレイヤーへのアクセスもありません。レイアウトを wrangler プロジェクトに import しようとしているなら、間違った境界を越えています。
生成された Worker が実際にどう動くかの概念的なメンタルモデルについては、SSR on a Worker (adapter mode) を参照してください。
zfb における SSG と SSR
デフォルトでは、zfb のすべてのページはビルド時に一度だけ静的 HTML にレンダリングされます(SSG)。これはコンテンツサイトにとって正しいデフォルトです。高速で、キャッシュ可能で、サーバーを必要としません。
リクエストごとに実行されなければならないルート(データベースの読み込み、セッションクッキーの確認、POST の処理など)は、単一のエクスポートで SSG から除外します:
// pages/api/products.tsx
export const prerender = false;prerender = false は、静的レンダリング中にこのページをスキップし、代わりに設定済みのアダプターに渡される SSR バンドルに含めるよう zfb build に指示します。
ルートが prerender = false をエクスポートしているのにアダプターが設定されていない場合、ビルドは問題のルート名を示すエラーとともに即座に失敗します。zfb はデプロイできないルートを黙って取りこぼすことはありません。
デフォルトエクスポートが受け取るのは props であり Request ではない
ページのデフォルトエクスポートは、SSG でも SSR でも、そのページの props オブジェクトで呼び出されます。受信した Request が渡されることはありません。prerender = false が変えるのはページをいつレンダリングするか(ビルド時に一度ではなく、リクエストごとに)だけであって、デフォルトエクスポートが何を渡されるかは変わりません:
{}—getStaticProps()をエクスポートしていない静的ルート。{ params }—paths()をエクスポートしていない動的ルート(スラグを事前に列挙できない、リクエストごとのページ)。それ以外の場合は、マッチした
paths()エントリの{ params, ...props }、またはgetStaticProps()が返した props。
Request を受け取るつもりで宣言した仮引数には、上のいずれかの形が渡ってきます。しかもこの間違いはエラーなくコンパイルが通ります。Request は、実際には props を受け取る仮引数に対しても妥当な TypeScript の型注釈だからです。失敗の仕方も静かです。request.method !== "POST" のチェックは undefined を読み、undefined !== "POST" が成り立つため、正しいメソッドを使ったリクエストも含めて、ルートは毎回 405 を返します。
// pages/api/products.tsx — ❌ declares `request`, actually receives props
export const prerender = false;
export default async function Products(request: Request) {
if (request.method !== "POST") {
// request.method is undefined here — this branch runs on every
// request, so the route 405s unconditionally.
return new Response("Method Not Allowed", { status: 405 });
}
// ...
}// pages/api/products.tsx — ✅ no parameter; read the real Request via getCloudflareContext()
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
export default async function Products() {
const { request } = getCloudflareContext();
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
// ...
}受信した Request がそもそも不要なら、仮引数を単に外す(export default async function Products())だけで十分です。既存のルートでこの症状を追っているところなら、prerender = false のルートが常に 405 を返す も参照してください。
prerender = false の dev と本番の同等性
zfb dev は prerender = false のルートを、Cloudflare が本番で実行するのと同じレンダリングコードを通して動かします。開発サーバーは埋め込みの V8 アイソレート(ビルド時 SSG を駆動するのと同じもの)をホストし、開発ルーターは prerender = false の URL をリクエスト時にそのアイソレートへディスパッチします。ビルド時でも静的スナップショットからでもありません。
同等性の保証はバイト単位ではなく意味的です。ステータスコード、レスポンスボディ、Content-Type は dev とデプロイされた Cloudflare アダプターの間で一致します。実行ごとに正当に変わる値(レスポンスに刻まれるタイムスタンプ、ランダムに生成されるリクエスト ID など)は異なってもよいとされます。
この同等性の保証が対象とするのはレンダリングコードだけで、Worker バインディングには及びません。 zfb dev は env/ctx を一切公開しないため、getCloudflareContext() を呼ぶルートは、同等性がどうであれ zfb dev 下ではその呼び出し自体を試せません。バインディングまでカバーするループについては、後述の ローカル開発 を参照してください。
これが実際に意味すること:
?id=…クエリパラメータに基づいて異なる HTML を返すページは、開発時のページ再読み込みのたびに正しい HTML をレンダリングします。前回のビルドの古いスナップショットではありません。例外を投げる SSR ハンドラは、
zfb build+ デプロイのあとに初めて失敗するのではなく、開発時にブラウザでインラインに V8 スタックトレースを表示します。プラグインの dev-middleware は依然として登録済みの URL を最初に要求します(プラグインルートは dev 専用のモックレスポンスなどのために SSR をオーバーライドできます)。SSR レイヤーはプラグインミドルウェアと静的ページキャッシュの間に位置します。
dev 側の SSR パスについて、実用上の注意が 1 つあります:
SSR のソース編集は自動で反映されますが、ブラウザは自動更新されません。 編集の tick ごとに zfb dev は再バンドルし、新しい V8 ホストを起動して実行中のサーバーに差し替え、古いホストをシャットダウンします。そのため prerender = false ルートへの次のリクエストは更新後のコードでレンダリングされます。ただし、SSR のみの編集では静的 HTML の書き込みが発生しないため、SSE の Page イベントが発火せず、開いているブラウザタブは自動的にはリロードされません。SSR ページを編集した後は、新しい出力を確認するためにブラウザタブを手動でリロードしてください。
prerender = false はリテラルなエクスポートでなければならない
zfb は prerender をビルド時の静的 AST 検査で検出します。ランタイムの評価ではありません。エクスポートはリテラルな export const 宣言でなければなりません:
export const prerender = false; // ✅ detected correctly次の形は検出されず、黙って SSG にフォールバックします:
// ❌ indirect assignment — not a literal export const
const flags = { prerender: false };
export const prerender = flags.prerender;
// ❌ function call — not a literal export const
export const prerender = computeFlag();同じ制約は frontmatter エクスポートにも適用されます。リテラルのみのコントラクトについては Frontmatter を参照してください。
Cloudflare アダプターの設定
アダプターをインストールし、zfb.config.json で名前を指定します:
pnpm add -D @takazudo/zfb-adapter-cloudflare{
"framework": "preact",
"adapter": "@takazudo/zfb-adapter-cloudflare"
}すると zfb build は dist/ の下に次を生成します:
すべての SSG ページの静的 HTML、
_worker.js+_zfb_inner.mjs—prerender = falseのルートを配信する Worker エントリ(薄いラッパーと、バンドルされた SSR ルート)、そしてSSR バンドルがインポートする Wasm モジュールごとの
<name>-<hash>.wasmファイル(例:index_bg-a1b2c3d4.wasm— これは esbuild 自身の--asset-names=[name]-[hash]の命名規則であり、zfb 固有の仕様ではありません)と.assetsignore。この ignore ファイルは_worker.js、_zfb_inner.mjs、生成された Wasm の basename をすべて列挙し、アセットサーバーがサーバーコードやコンパイル済みモジュールを公開ファイルとして配信しないようにします(これがないと/へのリクエストがそれをプレーンテキストとしてダウンロードさせてしまいます)。_ worker. js
この dist/ は Static Assets を備えた Cloudflare Worker として wrangler deploy でデプロイします。デプロイはプロジェクトルートの wrangler.toml が駆動し、main を dist/ に向け、dist/ の残りを組み込みのアセットサーバーに委ねます。Worker が動的ルートを処理し、アセットサーバーがそれ以外のすべて — プロジェクトにあれば public/_redirects ファイルも含めて — を処理します。ルール構文については Static Assets — _redirects を参照してください。
Cloudflare Pages のアドバンストモードは未検証
このアダプターは Workers Static Assets 上で検証されています。生成されるルート直下の _worker.js は Pages のアドバンストモードの慣習に従いますが、このアダプターについて Cloudflare Pages のアドバンストモードは検証されていません。専用のスモークテストが追加されるまで、サポートされたデプロイターゲットとして扱わないでください。
wrangler.toml
デプロイ設定はプロジェクトルートの wrangler.toml に置きます:
# wrangler.toml
name = "my-site"
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./dist"
binding = "ASSETS" # Worker 自身がアセットを探れるようにする — 下記参照
not_found_handling = "404-page"mainは wrangler を、生成された Worker エントリに向けます。compatibility_dateは Workers ランタイムの挙動を固定します。compatibility_flags = ["nodejs_compat"]は必須です。下の警告を参照してください。[assets] directory = "./dist"は静的な出力を組み込みのアセットサーバーに委ねます。[assets] binding = "ASSETS"は推奨です。これにより生成された_worker.jsにenv.ASSETSが公開され、SSR ルートへフォールスルーする前に GET/HEAD リクエストを自ら探ります(worker-wrapper.mjsのcanDelegateToAssets()は、このバインディングが無いとプローブを no-op にします)。デフォルトのrun_worker_first = falseの下でもプラットフォーム自身のアセットルーターがほとんどのヒットをすでに配信しているのに、なぜこのプローブが重要なのかについては SSR on a Worker — 内側のenv.ASSETSプローブはデッドコードではない を参照してください。not_found_handling = "404-page"は推奨です。マッチしなかったアセットパスは Worker にフォールスルーし、Worker がpages/と、あらゆる動的な404. tsx prerender = falseルートを配信します。
`not_found_handling = 'single-page-application'` を設定しないこと
single-page-application は、あらゆる未解決のパスに対して、Worker がリクエストを見る前にアセットサーバーが index.html を返すようにします。これは pages/api/*.tsx のような動的ルートを黙って壊します。未解決のパスが Worker に届くよう 404-page を使ってください。
スタイル付き 404 と、ルート自身が返す 404
not_found_handling = "404-page" と [assets] binding = "ASSETS" を両方設定していると、マッチしなかったパスはクライアントに届く前に 2 種類の異なる 404 レスポンスを生み出しえます — アセット層のスタイル付き dist/(pages/ からビルドされたもの)と、内側の Worker が同じミスに対して返すものです。_worker.js は次の固定された優先順位でどちらかを選びます:
内側の Worker が非 404 を返す — 本当に動的なルートがマッチした — 場合、そのレスポンスが無条件で勝ちます。
内側の Workerも、フレームワークの汎用的な not-found ボディ(Hono のデフォルトの
text/plain"404 Not Found"、または content-type のない素の 404)だけで 404 を返す場合 — スタイル付きのdist/が勝ち、訪問者はプレーンテキストの代わりにデザインされたページを見ます。404. html 内側の Worker がそれ以外の
content-typeで 404 を返す場合 — そのレスポンスは意図的なものとみなされ、そのまま返されます。text/htmlの 404 は自分自身の not-found ページをレンダリングするprerender = falseルートであり、application/jsonの 404 は意図的な機械可読の API エラーです。
自分で書いた prerender = false ルート(API エンドポイントなど)が独自の 404 を返す場合は、content-type: application/json を設定してください。素の text/plain の 404 はフレームワークのデフォルトと区別がつかず、スタイル付きページに道を譲ってしまいます。not_found_handling = "none" の下では、アセットの 404 はスタイル付きボディを持たないため、内側の Worker の 404 が常に表示されます。
アセットより先に Worker を実行する(run_worker_first)
このページのすべての例は、zfb のデフォルトである run_worker_first = false を前提にしています — プラットフォームのエッジアセットルーターが、_worker.js が実行される前にマッチする静的ファイルを配信します(全体のメンタルモデルは SSR on a Worker — ディスパッチフロー を参照してください)。run_worker_first = true を設定するとこれが反転し、プリレンダリング済みの静的ページへのヒットを含むすべてのリクエストが最初に Worker に到達します:
[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = trueコスト: これまでエッジから無料で直接配信されていたリクエストも含め、すべてのリクエストが Worker の起動コストを払うことになります — 「静的ヒットは Worker に一切触れない」という高速経路はもうありません。これは、あるルートが本当に静的配信より先に実行される必要があるときだけ使ってください。典型的なケースはパスワード/セッションゲートです。SSR ルートがすべてのリクエスト(プリレンダリング済みページへのものも含む)でクッキーを確認し、アセット層が保護対象のコンテンツを配信する前にログインページへリダイレクトします(これを実際に行っている password-gate の例 を参照してください)。
compatibility_flags = ['nodejs_compat'] は必須
アダプターはリクエスト単位の (env, ctx, request) コンテキストを AsyncLocalStorage(node:async_hooks 由来)を通して引き回し、getCloudflareContext() がそこから読み取ります。Workerd はデフォルトでは node:async_hooks を公開しません。wrangler.toml でオプトインする必要があります:
# wrangler.toml
compatibility_flags = ["nodejs_compat"]このフラグがないと、Worker は node:async_hooks を欠落モジュールとして示すエラーとともに起動に失敗します。より深い仕組みについては SSR on a Worker (adapter mode) を参照してください。
SSR ルートで Wasm をインポートする
SSR ルート(またはそこからインポートするヘルパー)は .wasm ファイルをデフォルトインポートできます。zfb はモジュールを SSR バンドル経由でコピーし、Worker にはコンパイル済み Wasm モジュールとして公開します。実用的なユースケースは、satori と @resvg/resvg-wasm による OG 画像生成です。以下の例では、loadOgFonts() が Satori のフォント設定を返すアプリ側のヘルパーであると仮定します:
// pages/api/og.tsx
import satori from "satori";
import { initWasm, Resvg } from "@resvg/resvg-wasm";
import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
export const prerender = false;
let resvgReady: Promise<void> | undefined;
function ensureResvgWasm(): Promise<void> {
return (resvgReady ??= initWasm(resvgWasm));
}
export default async function OgImage() {
await ensureResvgWasm();
const ogFonts = await loadOgFonts();
const svg = await satori(
<div style={{ color: "white", background: "black", padding: 48 }}>zfb</div>,
{
width: 1200,
height: 630,
fonts: ogFonts,
},
);
const png = new Resvg(svg).render().asPng();
return new Response(png, { headers: { "content-type": "image/png" } });
}SDK はデフォルトインポートを WebAssembly.Module として宣言します。これはインスタンス化済みモジュールでも型なしの URL 文字列でもありません。スターターのページファイルはすでに @takazudo/zfb をインポートしているため、そのアンビエント宣言は TypeScript プログラムの一部になります。プロジェクトが Wasm 専用ヘルパーをそのインポートグラフの外に置く場合は、モジュール宣言を複製するのではなく、include される宣言専用のブリッジを追加してください:
// src/zfb-wasm.d.ts
import "@takazudo/zfb";たとえば、tsconfig.json が src/ 全体をすでに含めていない場合は、このファイルが include リストの対象になるようにしてください。
生成される Wasm レイアウト
アダプターは bundle-relative な Wasm アセットを Worker エントリの隣へコピーし、.assetsignore に記録します。Resvg をインポートするビルドは次のようになります:
dist/
_worker.js
_zfb_inner.mjs
index_bg-a1b2c3d4.wasm
.assetsignore_worker.js
_zfb_inner.mjs
index_bg-a1b2c3d4.wasm最後のブロックは dist/.assetsignore の該当部分です。Wasm ファイルは Worker モジュールであり、公開静的アセットではありません。アダプターは既存の ignore ファイルを上書きせず、これらの必須エントリをマージします。
Wrangler のルールとサイズ制限
zfb がテスト済みの Wrangler のベースラインは 4.85.0 で、zfb preview はこれを 最小サポートバージョン として強制します。それより古い Wrangler はアップグレード案内とともに中断しますが、同じかそれより新しいものは続行します(新しいバージョンでは情報行を、未テストのメジャーバージョンでは警告を出力します)。プロジェクトの lockfile にあるバージョン(たとえば pnpm exec wrangler 経由)を使ってください。
標準の経路にはカスタムの [[rules]] ブロックは不要です。Wrangler のデフォルトモジュールルールは、main をバンドルする間にインポートした .wasm ファイルを CompiledWasm として分類します。カスタムルールはデフォルトをグローバルに置き換えません。重要な例外は、同じ型で fallthrough しない CompiledWasm ルールです。そのルールはデフォルトの Wasm ルールを抑制するため、マッチング動作が意図的なときだけ追加してください。
コンパイル済み Wasm は圧縮後の Worker パッケージ制限に含まれます。Workers Free は 3 MiB、Workers Paid は 10 MiB です。gzip サイズは wrangler deploy --dry-run で確認してください。画像レンダラーなどの Wasm 依存がそのパッケージを超える場合は、zfb Worker をこれ以上大きくするのではなく、別の Worker に移し、service binding を通して呼び出します:
[[services]]
binding = "OG_IMAGE"
service = "og-image"その後 SSR ハンドラーは env.OG_IMAGE.fetch(...) を呼び出せます。重い Wasm モジュールは専用 Worker に置かれます。
SSR ハンドラから Worker の env を読む
Cloudflare Worker の fetch ハンドラは (request, env, ctx) を受け取ります。アダプターは env と ctx を、リクエスト単位の スコープを通してページに引き回すため、SSR ルートは getCloudflareContext() でそれらを読みます:
// pages/api/whoami.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
interface Env {
ANTHROPIC_API_KEY: string;
}
export default async function WhoAmI() {
const { env, ctx } = getCloudflareContext<Env>();
ctx.waitUntil(reportToAnalytics()); // fire-and-forget background work
return new Response(env.ANTHROPIC_API_KEY ? "ok" : "missing key");
}Env ジェネリックがバインディングの形を絞り込むため、TypeScript は env.ANTRHOPIC_KEY のようなタイプミスを捕捉します。
SSR リクエストの内部でのみ呼び出すこと
getCloudflareContext() は Worker のリクエストスコープの外で呼ばれると例外を投げます。たとえばビルド時 SSG 中、そして zfb dev 下です。zfb dev は SSR のレンダリングコードそのものは実行しますが、Cloudflare のリクエストスコープを確立することはないため、この呼び出しはそこでも例外になります。実際に機能するリクエストスコープが得られるループについては、後述の ローカル開発 を参照してください。これは設計どおりです。バインディングを必要とするルートは必ず prerender = false をエクスポートし、そのバインディングに実際に到達するには wrangler dev / zfb preview の下で動かす必要があります。ルートを両方のモードで動かしたい場合は、エラーをキャッチして分岐してください。
D1 データベース(env.DB)を読む
は Cloudflare のサーバーレス SQLite です。D1 バインディングは他のどのバインディングともまったく同じように env に公開されます。アダプターはこれを特別扱いしません。バインディングの TypeScript の形を宣言してクエリします:
// pages/api/products.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
interface Env {
// `D1Database` comes from `@cloudflare/workers-types`. Install it as
// a devDependency if you want the full typed surface; otherwise a
// minimal structural shape like the one below works too.
DB: D1Database;
}
export default async function Products() {
const { env } = getCloudflareContext<Env>();
// Always use `.bind(...)` for user input — D1 prepared statements
// are parameterised, which prevents SQL injection.
const { results } = await env.DB
.prepare("SELECT id, name, price_cents FROM products ORDER BY id")
.all();
return new Response(JSON.stringify({ products: results }), {
status: 200,
headers: { "content-type": "application/json" },
});
}単一行の読み込みには .first() を使います:
const product = await env.DB
.prepare("SELECT * FROM products WHERE id = ?")
.bind(productId)
.first();書き込み(INSERT / UPDATE / DELETE)には .run() を使います:
await env.DB
.prepare("INSERT INTO orders (user_id, total_cents) VALUES (?, ?)")
.bind(userId, totalCents)
.run();D1 バインディングの配線
D1 は同じ wrangler.toml を通して Worker にバインドされます。バインディングの名前(下記の DB)が、env で読むプロパティになります:
# wrangler.toml — add alongside the [assets] block above
[[d1_databases]]
binding = "DB" # → env.DB inside the Worker
database_name = "webshop"
database_id = "<uuid>" # printed by `wrangler d1 create`エンドツーエンドのライフサイクル:
データベースを作成する —
wrangler d1 create webshop。これがdatabase_idを出力します。wrangler.tomlに貼り付けてください。マイグレーションを書く —
.sqlファイルをmigrations/(wrangler のデフォルト)の下に置きます。各マイグレーションは素の SQL(CREATE TABLEなど)です。マイグレーションを適用する —
wrangler d1 migrations apply webshop(ローカルの開発用データベースには--local、デプロイ済みのものには--remoteを追加)。デプロイする —
zfb buildを実行し、wrangler deployでdist/を Static Assets を備えた Worker として出荷します。
プレビューと本番を分ける場合は、名前付き環境の下にバインディングを宣言し、それぞれが独自のデータベースを持つようにします:
[[d1_databases]]
binding = "DB"
database_name = "webshop"
database_id = "<production-uuid>"
[[env.preview.d1_databases]]
binding = "DB"
database_name = "webshop-preview"
database_id = "<preview-uuid>"名前付き環境は別々の Worker
Workers では、[env.preview] のような名前付き環境は別個の Worker としてデプロイされます。wrangler deploy --env preview は、独自のバインディングと独自の URL を持つ my-site-preview Worker を出荷します。これはブランチ上の Cloudflare Pages のプレビューデプロイとは異なります。セマンティクスが異なり、より詳しい話はここでは対象外です。
KV ネームスペース(env.MY_KV)を読む
は Cloudflare の結果整合性を持つキーバリューストアです。KV バインディングは、上の D1 とまったく同じように env に公開されます。アダプターはこれを特別扱いしません:
// pages/api/greeting.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
interface Env {
// `KVNamespace` comes from `@cloudflare/workers-types`.
MY_KV: KVNamespace;
}
export default async function Greeting() {
const { env } = getCloudflareContext<Env>();
const stored = await env.MY_KV.get("greeting");
return new Response(stored ?? "no greeting set yet");
}書き込みには .put() を使います:
await env.MY_KV.put("greeting", "hello from KV");.get(key, "json") は、JSON.stringify(...) で保存した値を JSON としてパースし直します。
KV バインディングの配線
D1 と同様、KV ネームスペースも wrangler.toml を通してバインドされます。バインディングの名前(下記の MY_KV)が、env で読むプロパティになります:
# wrangler.toml — add alongside the [assets] block above
[[kv_namespaces]]
binding = "MY_KV" # → env.MY_KV inside the Worker
id = "<namespace-id>" # printed by `wrangler kv namespace create`ネームスペースを作成する —
wrangler kv namespace create MY_KV。これがidを出力します。wrangler.tomlに貼り付けてください。読み書きする — 上記のように
env.MY_KV.get(...)/.put(...)を使います。デプロイする —
zfb buildを実行し、wrangler deployします。
他のあらゆるバインディング — R2 バケット、Workers AI、Cache API、Durable Objects、service binding — も、SSR ルートからまったく同じ方法で読めます。wrangler.toml で宣言し、Env インターフェースにその型を追加し、getCloudflareContext() の中で env から読み取るだけです。単独の KV・Workers AI・リバースプロキシのレシピは Examples の一覧を参照してください。
ローカル開発
動作するエンドツーエンドの実例
zfb-example-webshopのデモは、まさにこのレシピを配線して動かしたものです。その dev:cf の package.json スクリプトと README の「ローカル開発」セクションは、以下で説明する 2 プロセスのループそのものです。スニペットを断片的にコピーするより全体が組み上がった 状態を見たい場合は、これをクローンしてください。(このデモはクライアント JS を一切 出力しませんが、それはショップ自身の設計上の選択であって zfb の制限ではありません。 ブラウザ JS が必要な場合、zfb は clientScript() 経由の .client.* クライアント スクリプトをサポートしています。)
アプリをローカルで動かす方法は 2 つあり、それぞれ異なる問いに答えます:
zfb dev— 高速なページ作成ループ。prerender = falseのルートの SSR レンダリングコードを埋め込みの V8 アイソレートを通して動かします(上記のprerender = falseの dev と本番の同等性 を参照)。ただし Worker バインディングは一切公開しません。zfb dev下の SSR ルートでgetCloudflareContext<Env>()を呼び出すと、Cloudflare request scope がないため 例外を投げます。そのためenv.DB(または他のバインディング)を読むルートはここでは動作しません。 バインディングに触れないルートのレイアウト、スタイリング、ルーティングの反復作業に 使ってください。wrangler dev— バインディングをリアルに再現するループ。ビルドされた_worker.jsをローカルの D1 データベース(.wrangler/下の SQLite ファイル)に 対して動かし、実際のcompatibility_flagsを反映し、本物のバインディングのバグを 表面化します。Worker エントリとアセットディレクトリはwrangler.toml(main+[assets])から発見するため、位置引数のdist/はありません。envを読む SSR ルートの作業をするときはこちらを使ってください。(zfb previewはこの同じ受け渡しを ラップします。いくつかの事前チェックを実行してからwrangler devを exec します。)
このセクションの残りはバインディングをリアルに再現するループについて説明します。
初回セットアップ
ローカルの SQLite データベースに D1 マイグレーションを適用します(冪等 — 再実行しても 安全です):
wrangler d1 migrations apply webshop --localwebshop 引数は wrangler.toml の database_name と対応しています。存在しない場合は . が作成されます。
編集→反映のループ
2 つのプロセスを並べて動かします: wrangler dev(main エントリまたは dist/ 下の アセットが変更されると自動リロード)と、ソースファイルを編集するたびに zfb build を 再実行するウォッチャーです。最もきれいな方法は devDependencies の concurrently + chokidar-cli を使うことです:
pnpm add -D concurrently chokidar-cli次に dev:cf スクリプトを package.json に追加します(ウォッチのグロブはプロジェクトの レイアウトに合わせて調整してください):
{
"scripts": {
"dev:cf:setup": "wrangler d1 migrations apply webshop --local && pnpm build",
"dev:cf": "pnpm dev:cf:setup && concurrently --names 'wrangler,watch' --kill-others 'wrangler dev --port 8788' \"chokidar 'pages/**/*.tsx' 'components/**/*.tsx' 'layouts/**/*.tsx' 'lib/**/*.ts' 'styles/**/*.css' --command 'pnpm build' --initial false --debounce 200\""
}
}次を実行します:
pnpm dev:cfTSX または CSS ソースファイルを編集すると、おおよそ 1〜2 秒でブラウザに変更が反映されます。 ウォッチャーが 200 ms デバウンスし、pnpm build が dist/ を再出力し、 wrangler dev が _zfb_inner.mjs の内容の変更に気づいて Worker を自動リロード します。Ctrl-C で両プロセスがきれいに終了します。
`pnpm dev` と `pnpm dev:cf` を同時に実行しないこと
zfb dev の predev ステップ(rm -rf dist .zfb .zfb-build)は、wrangler devが現在配信中の dist/ ディレクトリを消去します。wrangler プロセスは、その後のリビルドが リロードをトリガーしなくなる劣化状態に入る可能性があります。一度に一つのループを選んで ください。誤って両方を実行してしまった場合は、すべてを停止し、pnpm build を再実行して から pnpm dev:cf を再起動してください。
トラブルシューティング
ポート 8788 で Address already in use。 別の wrangler dev がまだ動いて います。lsof -ti TCP:8788 -sTCP:LISTEN | xargs kill で終了させるか、wrangler の 呼び出しに --port 8789 を渡してください。-sTCP:LISTEN フィルタが重要です。素の lsof -ti TCP:8788 は接続中のブラウザ/クライアントの PID も返すため、これがないと xargs kill が無関係なアプリを巻き添えにする可能性があります。
zfb preview が "adapter mode requires a wrangler config" で中断する。 アダプターモードでは zfb preview は wrangler dev に処理を引き渡し、wrangler dev は Worker エントリとアセットディレクトリをすべて自身の設定から発見します。そのため zfb は設定ファイルの存在を事前チェックし、見つからないときは明確なエラーで中断します:
preview: adapter mode requires a wrangler config at the project root
(wrangler.toml | wrangler.jsonc | wrangler.json), but none was found in <dir>.
Minimal example (wrangler.toml):
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./dist" wrangler.toml で示した wrangler.toml を作成して再実行してください。
Worker が node:async_hooks を名指しするエラーで起動に失敗する。 wrangler.toml に compatibility_flags = ["nodejs_compat"] がありません。アダプターは node:async_hooks をトップレベルでインポートするため、このフラグがないと Worker は そもそも起動しません。バインディングが欠けた状態でページを配信するのではなく、起動 自体に失敗します。このページの前にある "compatibility_flags = ['nodejs_compat'] は 必須" の警告を参照してください。このフラグはアダプターが env を SSR ルートに 引き渡すための必須条件であり、任意のオプトインではありません。
ページは表示されるが env.DB が undefined。 Worker は起動しています(つまり nodejs_compat は設定済みです)が、D1 バインディングがそこに届いていません。 wrangler dev は Worker エントリとそのバインディングの両方を wrangler.toml(main、 [assets]、[[d1_databases]])から解決するため、そこのバインディング名 (binding = "DB")が env で読むプロパティと一致していること、そしてそのバインディングを 宣言する wrangler.toml のあるプロジェクトルートから wrangler dev を起動したことを 確認してください。
編集後もブラウザに古いコンテンツが表示される。 通常、直前の pnpm build が失敗して います。concurrently の出力内の [watch] ストリームでビルドエラーを確認してください。 wrangler のリロードは dist/ が実際に更新されたときにのみ発火します。
カート / D1 データが消えた。 ローカルの SQLite DB は . 下に 存在し、リビルドをまたいで永続します。.wrangler/ を削除した場合、別の作業 ディレクトリに切り替えた場合、または wrangler.toml の database_name を変更した場合に リセットされます。それらの後に wrangler d1 migrations apply webshop --local を再実行 して新鮮なスキーマを取得してください。
なぜ 1 つではなく 2 つのプロセスなのか
zfb build は(小さなプロジェクトではサブ秒で)十分に高速なため、ユーザーランドの ウォッチャーを通じて保存のたびに実行することは、組み込みの --watch モードと区別が つきません。2 プロセスのレシピは、wrangler の Worker リロードの動作を、再実装不要な ブラックボックスとして保持します。