静的アセット
画像・SVG・フォント・favicon・robots.txt など、バイト単位でそのまま配信するファイルを zfb の public/ ディレクトリ経由でどう配信するか。
このページの内容
静的ファイル(画像・SVG・フォント・favicon・robots.txt・JSON マニフェスト、あらゆるバイナリ)を public/ ディレクトリ経由で配信する方法を説明します。URL の規約、dev/prod の一致保証、ファイル名がページと衝突したときの優先順位ルール、base マウントプレフィックスとの相互作用、そして代わりに TSX の import を使うべきケースを扱います。
zfb はコード以外のアセットを 1 つのディレクトリ public/ で扱います。ファイルを入れて絶対 URL で参照すれば、同じ URL が zfb dev・zfb preview・ビルドが出力する静的な dist/ のいずれでも動作します。インストールするプラグインも、書くべき import も、壊しうるバンドラのステップもありません。
規約
public/ 内のものはすべてサイトルートでそのまま配信されます。public というセグメントは URL に 現れません。
public/favicon.ico → /favicon.ico
public/logo.svg → /logo.svg
public/robots.txt → /robots.txt
public/img/hero.png → /img/hero.png
public/fonts/Inter.woff2 → /fonts/Inter.woff2サブディレクトリは保持されますが、トップレベルの public/ という名前は取り除かれます。/ へのリクエストは、dev では <project_ に、zfb build 後は dist/ に解決されます。
アセットの参照
絶対 URL を使ってください。アセットパスはレンダリングされた HTML に現れるものと一致します。
// pages/index.tsx
export default function Home() {
return (
<main>
<img src="/logo.svg" alt="Site logo" width={128} height={32} />
<link rel="icon" href="/favicon.ico" />
</main>
);
}CSS でも同じ方法が使えます。URL はブラウザが最終的にリクエストするものそのものです。
/* styles/global.css */
.hero {
background-image: url("/img/hero.png");
}
@font-face {
font-family: "Inter";
src: url("/fonts/Inter.woff2") format("woff2");
}パッケージから import された CSS: 相対アセット参照
上記の絶対 URL ルールは、自分で書く CSS — プロジェクトのグローバルスタイルシートと、それが自分のソースツリーから @import するもの — についてのものです。node_modules にインストールされた npm パッケージから @import されたスタイルシート には適用されません。パッケージのスタイルシートは自分自身のディレクトリを基準に書かれているため、相対 url() でファイルを参照するのが普通です。
/* node_modules/some-ui-kit/dist/styles.css */
@font-face {
font-family: "Some UI Kit";
src: url("./files/font.woff2") format("woff2");
}zfb はこの参照をスタイルシート自身の位置を基準に解決し、参照先のファイルをコンパイル済み CSS の隣にコンテンツハッシュ付きアセットとして — {stem}-{hash8}.{ext} として — 出力し、url() をそれを指す相対参照 . に書き換えます。ブラウザから見える URL は常に / です。ディスク上の実体は、zfb build では dist/assets/(outDir をデフォルトから変更している場合はその設定先)に、zfb dev の実行中は . に置かれます。同じファイルを複数回 import しても出力は 1 回だけです。元の参照に付いていた ?query や #fragment は、書き換え後の参照にもそのまま保持されます。これはフォントに限らず、パッケージのスタイルシートが参照するあらゆる種類のファイル(画像なども含む)で動作します。
自分で書く CSS は影響を受けません — リンクされたワークスペースパッケージも含みます。 グローバルエントリ、プロジェクトローカルで @import するスタイルシート、そして pnpm ワークスペースでリンクされた兄弟パッケージから @import するスタイルシート(そのカノニカルパスは実際には node_modules にインストールされていないため、node_modules の外を指します)は、いずれも上記の絶対 URL の規約(アセットの参照)をそのまま維持します — これらに書かれた相対 url() は、これまでどおりそのまま素通しされます。カノニカルパスが node_modules の内側を指すスタイルシートだけが、パッケージからの import として扱われます。public/ のコピーも、data: や絶対 URL、フル URL の参照も、この書き換えの対象には一切なりません。この違いは、何かを opt-in するかどうかではなく、スタイルシートのカノニカルパスがどこを指すか によるものです。あなたの CSS はこれまでどおり動作し続け、node_modules 内のパッケージの CSS がアセット出力の対象になるのは、それが自分自身と一緒にファイルを配布する他の手段を持たないからです。
失敗する場合
パッケージのスタイルシートの相対 url() を出力可能なファイルへ解決できない場合 — 参照先のパスが存在しない、通常のファイルでない、読み取れない、あるいはパッケージ自身のディレクトリの外を指している場合 — ビルドは壊れたスタイルシートを出力するのではなく失敗します。エラーはパッケージ名・ソーススタイルシート・参照を示します。
error: cannot emit `url()` asset from an imported package stylesheet
package: {name}@{version}
stylesheet: {canonical source path}
reference: url({raw reference})
reason: ...対処方法: パッケージを再インストールする(インストールが不完全でファイルが欠けている可能性があります)。あるいは、そのファイルを public/ にベンダリングし、パッケージの相対参照に頼る代わりに絶対 URL で参照してください。
帰属元は CSS コンパイラのソースマップに由来する
url() の参照がどのパッケージに帰属するかは、CSS コンパイラ自身のソースマップによって決まります。zfb はそのマッピングを信頼し、検出できる不整合があればビルドを失敗させます。コンパイラ側のソースマップにバグがあれば参照の帰属を誤る可能性は理論上ありますが、実際にはファイルが誤って帰属付けられた場所に見つからないため、大きなビルドエラーとして表面化します。唯一サイレントに残るケースは偶然の一致です — 誤ったソースディレクトリに、たまたま同じ相対パスのファイルが存在する場合です。これは zfb が把握したうえで受け入れている既知の残存的な制約であり、見落としではありません。
`zfb dev` は参照先のアセットファイルを監視しない
ウォッチャーが見ているのはプロジェクトのソースルートであって、url() が指すパッケージ側のアセットファイルそのものではありません。スタイルシートそのものは監視されます — node_modules は監視ルートの外にありますが、zfb はブート時に CSS エントリの @import グラフを解決し、解決された各実体パスを追加の監視対象として登録するためです。そのため、@import 経由でたどり着くパッケージのスタイルシートを編集すれば、CSS の再コンパイルは走ります。その url() が参照するファイル側には、この登録がありません。dev サーバーの実行中にそれらを差し替えても — 例えば node_modules 内の .woff2 を別のファイルで上書きしても — ウォッチャーイベントは発生しないため CSS は再コンパイルされず、配信されるコンテンツハッシュ付きのコンパニオンは、別のきっかけで CSS がリビルドされるまで古いバイトを返し続けます。新しいファイルを反映するには、zfb dev を再起動するか、そのパッケージを @import しているスタイルシート — こちらは監視対象なので、保存すれば CSS が再コンパイルされます — に触れてください。実際にこれが問題になることはまれです。パッケージのアセットは手で編集するようなファイルではなく、それが変わる通常の経路である pnpm install の後には、いずれにせよ dev を再起動することになるからです。node_modules 内の任意のアセットファイルを監視対象に含めないのは、意図的なスコープの判断です — @import グラフの経路を url() の指すファイルすべてに広げることには、監視範囲が意図せず狭まるという現実的なリスクが伴います。
静的アセットをモジュールとして import しない
zfb は public/ に対してバンドラを実行 しません。以下のようなパターン(Vite・webpack などのツールチェーンでよく見られるもの)はここでは動作しません。
// ❌ 静的ファイルにこれをしてはいけません。
import logoUrl from "../public/logo.svg";
import heroImg from "./hero.png";これらの import を URL に変換するアセットパイプラインは存在しません。代わりに絶対 URL の形式(src=)を使ってください。import は コード(islands が使う .ts・.tsx・.css モジュール)には依然として正しい答えですが、ブラウザにそのまま取得させたい画像・フォント・SVG のようなバイナリファイルには適しません。
CSS がストロークや塗りなどをスタイルできるよう SVG を JSX としてインライン化する必要が本当にある場合は、SVG マークアップを TSX コンポーネントにコピーしてください。それはコードの経路です。public/ はバイト単位そのままの経路です。
dev / prod の一致
dev サーバーとプロダクションビルドは URL の形について一致します。これは偶然ではなく保証です。
zfb dev—public/内のファイルは リクエストごとにディスクからライブで 配信されます。ページハンドラは、ページキャッシュのミスと<project>/.zfb-build/dev-pages/のミスのあと、<public_root>/<path>からの読み取りにフォールバックします。public/ディレクトリには URL プレフィックスもトップレベルのnest_serviceマウントもなく、ファイルはサイトルートに直接現れます。(注意: コンパイル済みの CSS と islands バンドルはdist/assets/から配信されますが、ルートごとの HTML はdist/ではなく.zfb-build/dev-pages/に書き出されます。)zfb build—copy_public_dir(crates/内)がzfb/ src/ commands/ build. rs public/配下のすべてのファイルをdist/<rel>へ再帰的にコピーします。エッジ CDN が配信する静的なdist/ツリーは、dev でブラウザが見たものと同じ形です。
つまり、ページに一度書いた <img src= は、条件分岐・環境チェック・withBase 風のヘルパーなしで両モードで動作します。
dev は `dist/` ではなく `public/` から配信する
public/ を dist/ に実体化(コピー)するのは zfb build だけです。zfb dev はpublic/ を dist/ にコピーしません — リクエストされた各静的ファイルを、その場でpublic/ から直接読み取ります。覚えておく価値のある帰結として、dev では読み取るべきdist/<静的ファイル> は存在しません。開発中に、public/ に置いたファイルを dist/ から 取りに行くツールがある場合、それは見つかりません。そのツールは代わりに public/ を指すように するか、先に zfb build を実行してください。これは zfb が dev で常に使ってきた配信直結 (serve-direct)モデルそのものです。逆だと思い込みやすいため、ここで明示しています。
dev の起動とライブリロード
配信直結モデルから直接導かれる dev サーバーの挙動が 2 つあります。どちらも開発中に何を期待できるかを左右するため、知っておく価値があります。
public/ は監視ルートではないので、静的アセットの編集はライブリロードされません。 dev ウォッチャーは pages/・content/・components/・layouts/・styles/・data/・設定ファイル・ツリー外のコレクションパスを追跡しますが、public/ は追跡しません。したがって public/ 配下のファイルを編集・追加・削除しても、ウォッチャーイベントは発生せず、ライブリロードも起こりません。そもそも必要ありません。ファイルはディスクからライブで配信されるため、次のリクエストが返すのはすでに新しいバイトです。ページをリロードする(またはアセットを再リクエストする)と変更が見えます。
静的アセットの変更では自動リロードされない
dev サーバーの実行中に public/ を変更しても、ブラウザのタブは自動リフレッシュされません。変更はディスク上で即座に反映されますが、それを拾うには手動リロード(またはアセット URL への新しいリクエスト)が必要です。これは zfb においてライブリロードを引き起こす編集だったことが一度もないため、これまで動いていたプロジェクトが壊れることはありません。ただ、public/ への保存でページがリフレッシュされると期待していた場合、それは起こりませんし、これまでも確実に起きたことはありませんでした。
ブートは public/ のサイズに比例しません。 zfb dev はプロジェクトを走査する前にリスナーを bind し、public/ は走査/監視するツリーから除外されます。大きな静的アセットディレクトリ — 数千枚の画像や、大きなシンボリックリンクツリー — があっても、サーバーが接続を受け付け始める瞬間が遅れることはもうありません。これは 静的アセット/監視ツリーのサイズ からの独立であって、プロジェクト全体の規模からの独立ではありません。初回レンダリング・CSS バンドリング・アイランドバンドルは、依然としてページ数・アイランド数・ソースファイル数に比例します。ブートの順序の全体像は dev モードのライフサイクル — ブートは bind ファースト を参照してください。
移行: ほとんどのプロジェクトで対応は不要
配信直結モデルは新しいものではありません。zfb dev は常に public/ をディスクから配信してきており、開発中に public/ を dist/ に実体化したことは一度もありません。移行すべき「dev から dist/ へのコピー」ステップは存在しません。消費者から見える事実は上記の 2 点だけです。public/ は監視ルートではない(ので静的アセットの変更はライブリロードされません — これらはもともと no-op のイベントだったので、これまで動いていたものは何も壊れません)こと、そして dev のブートが public/ のサイズに比例しなくなったことです。唯一確認すべきなのは、開発中に静的ファイルを dist/ から読み取るツールです。dev ではそのファイルは dist/ ではなく public/ にあります。それ以外については、対応は不要です。
優先順位: ページが public ファイルに優先する
pages/ ルートと同じ URL を持つ public/foo ファイルを持つことは可能です(通常は意図しないものですが)。zfb はこれを決定論的に解決します。
プラグインの dev ミドルウェア で
/を主張するものが最初に実行されます。foo ページキャッシュ —
pages/のレンダリング出力が次に優先されます。foo. tsx .zfb-build/dev-pages/ディレクトリ — dev の HTML ルート(dev パイプラインが書き出したルートごとのファイル)が次にチェックされます。/(CSS、islands バンドル)はassets/ * dist/assets/から配信されます。public/ディレクトリ — 上記すべてがミスした場合にのみ参照されます。それ以外は 404。
したがって、同名の TSX ページは常に public ファイルを覆い隠します。逆は不可能です。public/foo がルートを上書きすることはできません。ページも主張する URL に静的ファイルを置きたい場合は、どちらか一方をリネームしてください。
base との相互作用
zfb.config.ts が base プレフィックス(例: サブパス配下へのデプロイのための base: "/pj/site/")を設定すると、public/ 内のファイルもそのプレフィックスの下へ移動します。
config: base: "/pj/site/"
public/logo.svg → /pj/site/logo.svg (dev と prod)dev サーバーの serve_page フォールバックも、ビルド時の copy_public_dir も、このプレフィックスを尊重します。プロジェクトの他の部分と同じやり方で HTML 内のアセット URL を書いている限り(通常は markdown / TSX パイプラインがすでに実行しているリンクリライターを経由して)、プレフィックスは自動的に適用されます。
_redirects — リダイレクト/リライトルール
public/_redirects ファイルを置くと、Cloudflare Workers Static Assets の _redirects フォーマット(文書化されたサブセット — サポート範囲は以下を参照)に従ってリダイレクト/リライトルールを宣言できます。1 行に 1 ルールです。
# public/_redirects
/old-page /new-page 301
/blog/* /articles/:splat
/blog/:slug /articles/:slug 301
/api/* /api-handler 200sourceには単一の splat(*、ターゲット側では:splatとしてキャプチャされます)と、任意個の:nameプレースホルダーセグメントを含められます。各:nameセグメント(上記の:slugなど)はパスセグメントを 1 つだけキャプチャし、target側でも同じ名前で再利用されます。statusは省略可能で、デフォルトは 302 です。301/302/303/307/308はクライアントをリダイレクトし、200は リライト します — ブラウザの URL を変えずにtargetのコンテンツを配信します(同一サイト向けターゲットのみの軽量なリバースプロキシ)。ルールは ファイルに書かれた順に 試され、最初に一致した
sourceが勝ちます。200のリライトは一度だけ解決されます — そのターゲットがルールセットに対して再度マッチングされることはありません(チェーンなし)。評価されるのは
GET/HEADリクエストだけです。これは実際の Workers Static Assets がアセット層(したがって_redirects)をこの2つのメソッドに対してしかプローブしない挙動を反映しています。それ以外のメソッドは_redirectsを完全にバイパスします。リクエストのクエリ文字列はリダイレクトでは保持(付加)されますが、リライトでは破棄されます。リライトではクライアントに見える URL が変わらないためです。
不正な行(不正なステータス、トークンの欠落、外部 URL をターゲットにした
200ルールなど)は警告付きでスキップされます — 壊れた_redirectsファイルがzfb devやzfb previewの起動をブロックすることは決してありません。
どこで動くか: zfb dev は起動時にこれをロードし、public/_redirects への編集のたびにライブリロードします(フルリビルドなし)。zfb preview(静的モード)は出力ルートから起動時に一度だけロードします — 静的 preview は再ビルドも監視も行わないため、ルールを変更したら新しく zfb build してから再起動する必要があります。アダプタモードの preview(wrangler dev)と実際にデプロイされた Worker は、zfb のエンジンを経由せず Workers Static Assets を通じて ネイティブに _redirects を尊重します — そこでは zfb はファイルを一切パースしません。どのモードでも、あるURL に対するプラグインの devMiddleware/previewMiddleware の登録は、その同じ URL に対する _redirects ルールより常に優先されます。静的 preview 固有の base プレフィックスに関する注意点は CLI リファレンス — zfb preview を参照してください。
出力ルートでの成果物: zfb build は public/_redirects をそのまま <outdir>/_redirects (出力のルート — copyPublicWithBase が有効なときでも base の下にネストされません。これは Cloudflare のプラットフォームがルートに見つけることを期待する config であり、配信対象のアセットではないためです)へコピーします。通常の copy_public_dir パスからは除外されるため、これが素の静的ファイルとして二重に扱われることはなく、/ を直接リクエストすると(zfb dev でも zfb preview でも)常に 404 になります。
設定
このディレクトリは設定可能です。デフォルト以外を指すには zfb.config.ts に publicDir を追加します。
// zfb.config.ts
import { defineConfig } from "@takazudo/zfb/config";
export default defineConfig({
publicDir: "static",
});デフォルト: "public"。パスはプロジェクトルートからの相対で解決されます。ディレクトリが存在しない場合は黙って no-op になります。すべてのプロジェクトに必要なわけではありません。
デプロイ再配置パイプライン向けのフラットコピー
デプロイパイプラインが dist/ ツリー全体を base のサブパス配下に再配置する場合 — 例えば cp - を実行するようなワークフロー — public アセットを dist/<base>/... の下に置いてしまうと、二重にネストしたパス(deploy-)ができてしまいます。copyPublicWithBase: false を設定すると、public アセットは代わりに dist/ のルートへフラットにコピーされます。
export default defineConfig({
base: "/pj/site/",
copyPublicWithBase: false,
});この設定では public/ は dist/ に配置されます。cp - のあとは deploy- に到達し、/ として配信されます — これはページが withBase() 経由で参照するのと同じ URL で、二重ネストは起きません。
zfb preview の注意点: copyPublicWithBase: false の場合、zfb preview では base プレフィックス付きの public アセット URL が 404 になります。フラットコピーは dist のルートに存在し、zfb preview はデプロイ側の再配置をシミュレートしないためです。これはフラットコピー方式が持つ想定内のトレードオフであり、本番デプロイには影響しません。
public/ に入れないもの
public/ が適した置き場所:
サイト全体のアイコンと favicon(
favicon.ico・apple-touch-icon.png)Open Graph / ソーシャルシェア用画像
robots.txt・humans.txt・security.txtWeb アプリマニフェスト(
manifest.webmanifest)自前でホストするフォント
多くのページから絶対 URL で参照される装飾的な画像
public/ が適さない置き場所:
変換するソース画像(リサイズ・最適化・AVIF/WebP への変換)。zfb には組み込みの画像パイプラインがありません。変換が必要なら帯域外で実行し(例:
prebuildスクリプト経由)、最適化された出力をpublic/にチェックインするか、別のツールを使ってください。islands のコード依存。
"use client"島がインポートする TSX / JSX / TS / CSS は、島の隣に置いてバンドルすべきです。コードをpublic/に置くとバンドラを完全にスキップしてしまい、ブラウザはランタイムが実行できない生のソースを取得することになります。拡張子が示すものと異なる
Content-Typeが必要なファイル。zfb は Content-Type をファイル拡張子から導出します。オーバーライドが必要なら、代わりに TSX ページ経由でファイルをレンダリングしてください(Non-HTML Pages を参照)。
関連
Project structure:
public/— ディレクトリレイアウトの概観。Non-HTML Pages — ヘッダーを制御したい場合や、ページをコレクションデータに依存させたい場合に、
.xml・.json・.txtを TSX ページ経由でレンダリングする方法。Islands — クライアントサイド JS の経路。ここで説明した静的アセットの経路とは別物です。