Workers Static Assets
スタンドアロン Worker から [assets] で静的ファイルを配信する方法と、ゲーティング・プレビュー URL の罠
Workers Static Assets モデル
Workers Static Assets は、スタンドアロン Worker が静的ファイルのディレクトリ(HTML、CSS、JS、画像)を Cloudflare のエッジから直接配信しつつ、動的リクエストには Worker コードを実行できる仕組み。静的 + SSR サイトにおける Cloudflare Pages の後継であり、別個の Pages プロジェクトを用意する代わりに、アセットディレクトリとリクエストロジックの両方を持つ 1 つの Worker をデプロイする。
wrangler.toml の [assets] テーブルで設定する:
name = "my-site"
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
run_worker_first = falsedirectory-- 配信する静的ファイルのフォルダ(ビルド出力)。binding = "ASSETS"-- アセットストアを Worker にenv.ASSETSとして公開し、Worker がプログラムからアセットを取得できる(env.ASSETS.fetch(request))。名前はアダプターやコードが期待するものと一致させる必要がある。not_found_handling-- 一致するアセットがない場合に何を返すか(後述)。run_worker_first-- Worker をアセットレイヤーより先に実行するかどうか(後述)。
アダプターが自動生成する
Astro などのフレームワークは dist/ エントリを出力し、binding = "ASSETS" を期待する。通常は Worker を一から書くのではなく、[assets] ブロックがアダプターの期待と一致していることを確認するだけでよい。
run_worker_first -- ゲーティングの罠
デフォルトは run_worker_first = false。これはアセットレイヤーが先に参照されることを意味する。GET/HEAD リクエストで一致する静的ファイルが存在すれば、Cloudflare はそれを直接返し、Worker スクリプトは実行されない。Worker が動くのは、一致するアセットがない場合だけ。
通常のサイトではこれがまさに望む挙動だ。静的ファイルは高速に配信され、Worker は動的ルートだけを処理する。しかしこれはリクエストのゲーティングを密かに壊す。
Worker がすべてのリクエストを認可・ゲートする目的(例:ステージングデプロイの Basic 認証や、プレビューホストの許可リストチェック)の場合、デフォルトの順序はそれを無効化する:
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
# Default false: a GET to a preview host returns 200 from the asset layer
# and never reaches the gate-wrapped worker -> the gate is silently bypassed.
run_worker_first = falseプレビューホストへの / への GET は、Worker が動く前にアセットレイヤーから 200 を返すため、認証チェックは決して実行されない。プレビューデプロイは密かにゲートが外れる -- 保護されているように見えて(Worker コードは存在する)実際にはされていない、本物のセキュリティホールだ。
修正は、Worker を先に実行させること:
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
# Worker runs on EVERY request first; it gates, then serves the asset
# itself via env.ASSETS.fetch(request) once the request is authorized.
run_worker_first = trueリクエストごとのゲーティングには run_worker_first = true が必須
Worker がすべてのリクエストを認可しなければならない場合、run_worker_first = true は任意ではない。デフォルトの false では、一致するアセットが Worker より先に配信されるため、静的ファイルに解決されるパスではゲートがバイパスされる。true に設定し、ゲートを通過した後に env.ASSETS.fetch() で Worker にアセットを配信させる。
プレビュー URL が消える罠
デプロイごとのプレビュー URL(wrangler versions upload --preview-alias が出力する *.workers.dev のバージョンプレビューホスト)は preview_urls で制御される。罠はこうだ:preview_urls はデフォルトで workers_dev に一致する。
つまり、本番を *.workers.dev で配信するのをやめるために workers_dev = false を設定した瞬間、省略された preview_urls も false に切り替わり、すべてのデプロイごとのプレビュー URL が密かに消える。これが典型的な「なぜプレビュー URL が動かなくなったのか?」という驚きだ。本番ルートを変えただけのつもりが、プレビューまで失っている。
修正は、preview_urls = true を明示的に設定すること:
name = "my-site"
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
# Don't serve production on *.workers.dev...
workers_dev = false
# ...but preview_urls defaults to match workers_dev, so an omitted value would
# also become false and kill ALL per-deploy preview URLs. Set it explicitly.
preview_urls = true
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
run_worker_first = falseトップレベルフィールドは [assets
TOML では、テーブルヘッダー以降のキーはそのテーブルにスコープされる。workers_dev / preview_urls が [assets] の下にあると、wrangler は「Unexpected fields found in assets field」と警告し、それらを密かに無視する。これらのトップレベルフィールドは [assets] テーブルの上に置くこと。
Not-Found 処理と SPA/SSG フォールバック
not_found_handling は、GET がどのファイルにも一致しなかったときにアセットレイヤーが何を配信するかを決める:
"404-page"--dist/を配信する(静的サイトジェネレーターで一般的)。各ルートが実ファイルで、未知のパスには 404 ページを表示したい SSG 出力に適する。404. html "single-page-application"-- 一致しないルートにdist/を配信し、クライアントサイドルーターが処理できるようにする。SPA に使う。index. html "none"-- ボディなしの素の 404 を返す。
[assets]
directory = "./dist"
binding = "ASSETS"
# SSG: unmatched GETs serve dist/404.html
not_found_handling = "404-page"アセットレイヤーは GET/HEAD のみを処理する
not_found_handling は GET/HEAD リクエストに適用される。POST やその他のメソッドはアセットレイヤーから配信されることはなく、常に Worker に到達する(run_worker_first が許す場合)。したがって POST /api/... は not_found_handling の影響を受けない。
.assetsignore
アセット directory 内の .assetsignore ファイルは、公開アセットストアから除外するファイルを .gitignore のように列挙する。よくある用途は、Worker エントリとその内部バンドルがダウンロード可能なファイルとして配信されないようにすることだ:
# dist/.assetsignore
_worker.js
_worker.js.map これがないと、dist/ は静的アセットとして公開取得可能になる。.assetsignore の内容はアダプターの出力ファイル名に依存するため、ビルド・デプロイツールがコミットせずデプロイ時に dist/ へ生成することが多い。
SSR のインデックス + 404 ページで、ブラウザからだけトップページが壊れる
not_found_handling が適用されるのはナビゲーションリクエストだけである。つまり sec-fetch-mode: navigate を伴うリクエスト — 人が URL を開いたときにブラウザが必ず送り、curl は決して送らないもの。アセットに一致しないナビゲーション以外のリクエストは、代わりに Worker へフォールスルーする。
この違いは、ごく普通の 3 つの選択が揃うまで表に出ない:
インデックスルートがサーバーレンダリング(
prerender = false)で、ビルドがdist/を出力しない。index. html not_found_handling = "404-page"。プロジェクトに 404 ページがあり、アセット層が配信できる
dist/が存在する。404. html
こうなると、ブラウザで / を開いてもアセットに一致せず、not_found_handling で応答され、404 ページが返る — Worker は一度も動かない。同じ URL を curl で叩くと Worker にフォールスルーして正常に描画される:
curl そのまま -> 200 <title>My Site</title>
curl -H 'sec-fetch-mode: navigate' -> 404 <title>Not found</title>
ヘッドレスブラウザ -> 404サイトは人間の訪問者全員に対して壊れており、HTTP レベルのチェックはすべて緑になる。
404 ページを消すと「偶然」直る — それに依存してはいけない
同じ SSR インデックス構成の姉妹プロジェクトが動いていたのは、404 ページを持っていなかったからにすぎない。配信すべき dist/ が無いのでリクエストが Worker へフォールスルーしていた。ファイルを 1 つ足せば同じ障害になる状態だった。SSR インデックスのアプリが今動いているなら、安全だと決めつける前になぜ動いているのかを確認すること。
修正は、Worker が担当するルートについて Worker を先に走らせること。新しめの wrangler はパターンのリストを受け付けるので、静的アセットは直接配信のままにできる:
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
# `/` はサーバーレンダリングで静的ファイルが無く、API ルートも Worker のもの。
# それ以外(例: /assets/*)は引き続き Worker を経由しない。
run_worker_first = ["/", "/api/*"]run_worker_first = true でも動作し、そちらのほうが単純だが、すべてのアセットリクエストで Worker が起動するコストがかかる。
検証は、実際にそのヘッダーを伴うリクエストで行うこと。wrangler dev はこの挙動を忠実に再現する:
curl -H 'sec-fetch-mode: navigate' http://127.0.0.1:8787/ # 200 を期待
curl -H 'sec-fetch-mode: navigate' http://127.0.0.1:8787/nope # 本物の 404 を期待
curl http://127.0.0.1:8787/assets/styles-abc123.css # 引き続き直接配信fetch() はこのヘッダーを送れないので、Node のスモークテストでは捕まえられない
Sec- で始まる名前は Fetch 仕様の禁止ヘッダー名であり、undici — したがって Node のグローバル fetch() — はこれを黙って落とす。リクエストは通常のものとして送出され、フォールスルーした 200 が返る:
node fetch + sec-fetch-mode -> 200 (ヘッダーが落とされる) curl + sec-fetch-mode -> 404 (ヘッダーが送られる)つまり fetch ベースのデプロイ後チェックは、ユーザーが決して生成しないリクエスト形状を試しており、まさにこのバグを緑のまま通過させる。ヘッダーをそのまま書き出す node:https(あるいは undici.request、実ブラウザ)を使うこと。
ルーティングの罠
上のゲーティングの罠と SSR インデックスの障害は、根本原因が同じだ -- アセットレイヤーが「ファイルを配信するか、Worker に渡すか」を決めるのは Worker コードが動く前であり、その境界線はリクエストの種類と使っている Wrangler のバージョンによって位置が変わる。同じ仕組みから、さらにいくつかの罠が生まれる。
ブラウザのナビゲーションは curl が通るゲートをすり抜けられる
Worker スクリプト(main)が設定されており、not_found_handling が設定されており、かつ互換性日付が 2025-04-01 以降(あるいは assets_navigation_prefers_asset_serving 互換性フラグが設定されている)場合、アセットに一致しないナビゲーションリクエストは not_found_handling から直接応答され、Worker には一切到達しない -- run_worker_first のデフォルトが false かどうかとは無関係に。ナビゲーションでないリクエスト(Sec-Fetch-Mode: navigate ヘッダーを持たない、curl やほとんどのサーバー間呼び出し)は、これまでどおり Worker へフォールスルーする。
「アセットに一致しない -> Worker へフォールスルーする」を暗黙のバックストップとして当てにしていた場合 -- 例えば、既知の公開ファイルでないパスすべてに対する catch-all の認可チェックなど -- そのバックストップは、プロジェクトが最新の互換性日付に乗った瞬間、実際のブラウザトラフィックに対しては静かに動かなくなる。「保護されている」はずのパスへの curl チェックは相変わらず Worker に到達して成功と報告するが、実際のブラウザ訪問者はみな not_found_handling の応答を受け取るだけで、ゲートは一切かからない。
curl は通るが、すべてのブラウザはすり抜ける -- セキュリティゲートが静かに開けっぱなしになっている
互換性日付が 2025-04-01 を超えたら、手動の curl チェックはブラウザが何を受け取るかについて何も証明しない。Sec-Fetch-Mode: navigate を伴うリクエストで検証すること(上の SSR インデックスの検証コマンドを参照)。あるいは曖昧さそのものを消す方法として、run_worker_first = true を設定し、リクエストの形に関係なく Worker を必ず実行させる。
完全なゲーティング修正は run_worker_first = true と、アセット自身を配信する Worker とのペアだ
run_worker_first = true はルーティングだけを変える -- すべてのリクエストが先に Worker に到達するようになる。それ自体は何もゲートしないし、アセットを配信もしない。Worker 自身が認可を確認し、一致するアセットをバインディング経由で自分で配信する必要がある:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const authorized = await checkAuth(request, env);
if (!authorized) {
return new Response("Unauthorized", { status: 401 });
}
// Gate passed -- now serve the matching static asset ourselves.
return env.ASSETS.fetch(request);
},
};これを追加せずにフラグだけ切り替えても修正としては不完全だ。Worker はすべてのリクエストで実行されるようになるが、明示的に認可した上で env.ASSETS.fetch() を呼ばない限り、サイトの静的ファイルを実際に返す手段がない。修正はこのペアである -- run_worker_first = true と認証済みの env.ASSETS.fetch(request) 呼び出し -- フラグ単体ではない。
配列形式の run_worker_first はコスト最適化であり、ゲートではない
Wrangler 4.20.0 以降では、run_worker_first にブール値の代わりにルートパターンの配列を渡せる:
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
# Only these paths invoke the Worker first; every path NOT listed here --
# including paths you haven't thought of yet -- serves straight from the
# asset layer with no Worker involvement at all.
run_worker_first = ["/oauth/callback", "/api/*"]これが存在するのは、クライアント重めの SPA で課金対象の Worker 呼び出しを減らすためだ -- ほとんどのナビゲーションは Worker のコストゼロでエッジから直接 index.html を配信する -- が、これはゲートとしても使える。ただしそれは、リストの外がすべて意図的に公開されている限定されたサブツリーに対してだけだ。/ だけが認証を必要とし、それ以外はすべて公開のつもりのサイトで run_worker_first = ["/admin/*"] を使うのは正当なセキュリティゲートだ -- admin サブツリーを保護しつつ、保護が要らない公開アセットについては Worker を呼び出さずに済む。
配列形式が誤りになるのは、catch-all あるいはデフォルト拒否の要件のときだ -- 「すべてのリクエストを認可する」「明示的にリストしない限り公開しない」といったケース。この場合、配列の外にあるパスにはゲートが一切かからず、列挙しなかったパスに対する暗黙の拒否は存在しない。後から追加したルートはデフォルトでゲートなしのまま公開される。要件がデフォルト拒否なら run_worker_first = true(あるいは前述のように Worker 内部でゲートする)を使うこと。配列形式は、既知の限定されたサブツリー向けに取っておく -- コスト最適化のためであれ、それ以外を意図的に公開したままにする特定のルート集合へのゲートのためであれ。
not_found_handling = "single-page-application" は常にルートの index.html を配信する
一致しないパスに対して、SPA フォールバックはトップレベルの dist/ の内容を 200 OK で配信する -- パスがどれだけ深くても関係ない。ディレクトリごとのフォールバックは存在しない。
[assets]
directory = "./dist"
binding = "ASSETS"
# Every unmatched path -- /app-a/settings, /app-b/reports, /anything --
# gets the SAME top-level dist/index.html. There is no per-directory variant.
not_found_handling = "single-page-application"この罠はマルチゾーンのビルドで表面化する。dist/ と dist/ を、それぞれが自分のサブツリーを持つ別々の SPA シェルとして出荷しているなら、フォールバックはその構造を完全に無視する。/ への、実ファイルに一致しないリクエストが受け取るのはルートの dist/ であって dist/ ではない -- ルートシェル自身のルーターが / を担当する設計になっていない限り。
run_worker_first と _redirects は、auto-trailing-slash が書き換える前のリクエストを見る
デフォルトの html_handling = "auto-trailing-slash" は foo/ をトレイリングスラッシュありで配信し、素の / リクエストは / へリダイレクトする:
GET /foo -> 307 Location: /foo/
GET /foo/ -> 200 (serves dist/foo/index.html) このリダイレクトはアセットレイヤー自身の挙動であり、リクエストが既にそこへルーティングされた後で初めて発火する。run_worker_first のパターンマッチングはそれより上流、つまり実際にリクエストされたままのパス名に対して行われる -- だから素の形(/、トレイリングスラッシュなし)に対して書いたパターンは / へのリクエストをちゃんと目にする。スキップされたりはしない。これは _redirects のルールも同じで、こちらも html_handling より前に評価される。
素の形だけで抜け落ちるのは、その先にあるものすべてだ -- リダイレクト後にブラウザがたどり着く正規の / という宛先や、/ のようにその下にネストされたものすべて。/ というエントリも、素の / へのリクエストはカバーしない -- グロブはその後に続く / を文字どおり要求するからだ。サブツリー全体に Worker を通したいなら、両方を明示的にカバーすること:
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
# "/admin" matches the request as originally made, before auto-trailing-slash
# would redirect it. "/admin/*" covers the "/admin/" destination and every
# path nested under it. Neither pattern covers the other -- list both.
run_worker_first = ["/admin", "/admin/*"]env.ASSETS.fetch() はパス名だけでマッチする
env.ASSETS.fetch(request) -- 上のゲートがリクエストを認可した後にアセットを配信するために使う呼び出し -- はURL のパス名だけでマッチする。クエリ文字列はアセットのマッチングに一切関与しない:
// Both requests resolve to the SAME asset -- the query string is ignored
// for matching purposes, even though it's still visible to your Worker code.
await env.ASSETS.fetch(new Request("https://example.com/report.pdf"));
await env.ASSETS.fetch(new Request("https://example.com/report.pdf?v=2"));バインディング経由でアセットのバリアントをクエリ文字列で選び分けようとしないこと -- 常に同じファイルに解決される。ハッシュ付きバンドルのバージョニングはファイル名自体で表現するべきものだ。コンテンツハッシュ付きファイル名と組み合わせる _headers パターンについてはハッシュ付きアセットのブラウザキャッシュを参照。
まとめ
| フィールド | デフォルト | 設定する場面 |
|---|---|---|
binding | -- | Worker(またはそのアダプター)が実際に env.ASSETS.fetch() を呼ぶ -> 設定する;Worker に main はあるがアセットファーストのルーティングに任せきりで env.ASSETS に一切触れない -> 省略する;assets-only Worker(main なし)-> 省略する。設定していると wrangler がハードエラーになる |
not_found_handling | "none" | SSG -> "404-page"、SPA -> "single-page-application" |
run_worker_first | false | Worker がすべてのリクエストをゲート・認可する必要がある(デフォルト拒否)-> true;一部のルートだけコスト上の理由で Worker-first にしたい、あるいは他をすべて意図的に公開したままにする限定サブツリーへのゲートにしたい -> パターンの配列;あるいはインデックスが SSR で dist/ が存在する -> ["/", ...] |
html_handling | "auto-trailing-slash" | run_worker_first でディレクトリのサブツリーをゲートする -> 素の形と / の形の両方をリストすること。それぞれがカバーする範囲は違う |
workers_dev | true | 本番を *.workers.dev で配信するのをやめる -> false |
preview_urls | workers_dev に一致 | workers_dev = false でもプレビューを残すため常に明示的に設定 |