コンテンツにスキップ

静的ファイル

プロジェクトは、ルートの public/ ディレクトリから静的ファイルを配信します。 ファイルはバイナリに埋め込まれるため、実行ファイルとアセットをまとめてデプロイできます。

public/ あなたが書く場所
favicon.ico
generated/app.css
images/logo.png
GET /public/images/logo.png

pw init がディレクトリと、それを埋め込む public.go を作るため、生成直後の プロジェクトでもすぐに配信できます。

実際にバイナリへ埋め込まれるのは、ソースの public/ ではなく dist/public です。 pw buildpublic/ のファイルをコピーまたは変換し、この ビルド用ツリーへ出力します。変換がなければ内容は完全に同じです。変換でファイル名が 変わる場合は、出力と参照の書き換えが dist/public 上で揃います。CDN やオブジェクト ストレージでアセットをアプリケーションとは別に配布するなら、この仕組みは使いません。

package publicassets
//go:embed all:dist/public
var embeddedPublic embed.FS
func init() {
middlewares.RegisterPublicFS(PublicFS())
}

public.go は生成物ではなく、ふつうのアプリケーションコードです。main がアセットに一切 触れないのは、この init 登録があるからです。フレームワークは起動時にファイルシステムを 受け取ってマウントします。server.public.enabled が有効なのに登録が無ければ、静かに 404 を 返すディレクトリではなく起動エラーになります。

その隣にビルドが public_manifest_pw_gen.go を書きます。こちらは生成物で、URL ごとに 表現・長さ・検証子・キャッシュ方針を持ちます。レスポンスはバイトを読むだけで、何も計算 しません。**マニフェストに無い URL は、ツリーに何があっても 404 です。**紛れ込んだファイルが 「ビルドが配信すると決めていないのに到達可能」になることを、これが防ぎます。

フレームワークが持つ静的ディレクトリの規約は public だけです。ユーザのアップロードや 生成したレポートなど、それ以外に配信したいものはふつうのルートで扱います。たいていは オブジェクトストレージのほうが適切な置き場所です。

キャッシュ方針はビルド時に決まる

Section titled “キャッシュ方針はビルド時に決まる”

あなたが書いた名前のまま残るファイルには public, no-cache と strong な ETag が付きます。 ブラウザは毎回再検証し、変わっていなければボディ無しの 304 で済みます。安定した名前が 正直に約束できるのはここまでです——次のビルドが同じ URL に別のバイト列を置くかもしれない のですから。

一方、ビルドが生成したファイルは自分のバイト列のダイジェストを名前に持ち (logo.4f2a91c07b3e.webp)、public, max-age=31536000, immutable が付きます。バイト列が 違えば URL が違うので、この約束は願望ではなく事実です。

ハッシュ名を付けるのが生成物だけなのには理由があります。名前をハッシュ化できるのは、 その名前へのあらゆる参照を書き換えられるときだけです。ビルドが書き換えるのは img と ビルド対象 scriptsrc、そしてスタイルシートの url() です。link href は書き換えない ので、スタイルシートはあなたが付けた名前のままです。

名前を保つことと再検証することは別の問題で、後者には逃げ道があります。どのアセットも リビジョンセグメント——そのファイル自身のバイト列のダイジェスト——を挟んだ URL でも 配信されています。

/public/app.css public, no-cache
/public/9f4c1e2a7b60d381/app.css public, max-age=31536000, immutable

バイト列も ETag も同じで、URL が 2 つあります。後者が「二度と変わらない」と約束できるのは、 中身が変わったファイルは別のアドレスで応答されるからです。そして古いアドレスは黙って別物を 返すのではなく 404 になります。ブラウザが永久に握っていても安全なのは、この一点によります。

どちらの URL を読むかはテンプレートの書き方が決めます。リテラルのパスにはリビジョンが無いので 再検証されます。AssetURL はビルドに尋ねます。

package templates
external AssetURL(name: string): url
export component Document(children: html?): html {
<html><head>
<link rel="stylesheet" href={AssetURL("app.css")}>
</head><body><slot /></body></html>
}
func AssetURL(name string) *url.URL { return &url.URL{Path: pw.PublicAssetURL(name)} }

pw init は両方を書き出すので、新規プロジェクトは最初からキャッシュが効きます。

引数は配信ツリー内のパス——"/public/app.css" ではなく "app.css"——です。マウント位置は ランタイム設定であって、それをテンプレートに書き下すと変更箇所が二重になるからです。URL 全体を 渡しても受け付けるので、既存テンプレートの移行は機械的な置換で済みます。

リビジョンが付かないものが 2 つあり、どちらも必要としていません。生成物は名前が既にダイジェストを 持っているので、セグメントは同じことを二度言うだけです。そして 外部ツリーのファイルは独立した成果物として出荷され、 ビルドはそのバイト列を読んでいません。

開発ループにはマニフェストが無いので AssetURL は素の URL を返し、編集は次のロードで見えます。 切り替えるものはありません。

使いどころは、他の何もリネームしてくれないアセット——スタイルシート、素の JavaScript です。 img src とビルド対象の script src はリテラルのままにしてください。ビルドが既に自分の ダイジェストを持つ名前へ書き換えており、関数の裏に隠すとその変換が効かなくなります。

頼むまで何も変換されません。pw add images がエンコーダとスイッチを 同時に入れます。これは意図的です——ツール無しのスイッチは何も変換せずにそう報告するだけで、 片方だけでは役に立ちません。

[assets.images]
enabled = true
quality = 75
avif = false
[assets.css]
minify = true
[assets.scripts]
enabled = true

それぞれが何をして、参照がどうなるかは次のとおりです。

どの変換経路へ入るかを決めるのは拡張子ですが、拡張子だけを中身の証明にはしません。ビルドが 先にバイト列と照合し、食い違うファイルはどの encoder に渡る前に拒否されます。ファイルは 名乗ったとおりのものでなければならないを 参照してください。

変換後 参照
URL の拡張子が .png.jpg.jpegimg src WebP。PNG は可逆、JPEG は非可逆。avif = true なら AVIF 表現も生成 ハッシュ付き URL に書き換え。両方あれば Accept で AVIF と WebP を選択
.ts.tsx を指す script src バンドルされた ES モジュールとソースマップ ハッシュ名に書き換え
.css ファイル minify し、url() の参照先を変換後に向ける 変わらない。スタイルシートは自分の URL を保つ
.js ファイル minify のみ。バンドルしないのでモジュールはモジュールのまま 変わらない
それ以外 そのままコピー 変わらない

負けた変換は見送られます。元より大きくなったエンコードは参照を元ファイルに残したまま、 理由をビルド出力に書きます。誰の得にもならない派生ファイルは、手順が増えただけの重さ だからです。

元ファイルが出荷ツリーから落ちるのは、ビルドから見えるすべての参照を書き換えられたとき だけです。書き換えられない参照——meta タグの中のパス、スクリプトが組み立てる URL——が 残っていれば元ファイルは保持され、その旨が報告されます。知らないうちに行われた変換で ページが画像を失うことはありません。

スクリプトビルドが有効なとき、TypeScript は配信するファイルではなく入力です。したがって エントリが import しただけのモジュールも配信されません。ブラウザはそれを実行できないから です。保持の規則は同じで、Go のコードなどがまだ名前を挙げている .ts は残り、理由が 報告されます。

出力のうちソースマップだけは、ビルドの起動方法で変わります。 pw dev は常に書き、 pw build --debug は成果物に残します。 素の pw build はマップも、それを名指す sourceMappingURL コメントも書きません。 マップは authored な TypeScript を埋め込んでいるので、配ることは自分のソースを 要求した相手全員に渡すことであり、staging と本番にその必要はありません。

ビルドされるスクリプトには type="module" が要る

Section titled “ビルドされるスクリプトには type="module" が要る”

ビルドが吐くのはモジュールです。そしてモジュールを素の <script src> で読むと、ロード時に 構文エラーになります。ページは描画され、スクリプトだけが静かに消える——ビルドはこれを 出荷せずに拒否し、テンプレートのファイル名と行番号を挙げます。

<script type="module" src="/public/js/app.ts"></script>

AVIF はリクエストごとに選ばれる

Section titled “AVIF はリクエストごとに選ばれる”

avif = true にすると、画像は同じ URL の裏にもうひとつの表現を持ち、どちらを返すかは Accept が決めます。VaryAccept が入るのは実際に表現が2つある URL だけなので、 それ以外はキャッシュに1つのバリアントだけが載ります。

<picture> 要素をビルドが書くことはありません。img を包むと要素ツリーが変わり、CSS の コンビネータや DOM を辿るスクリプトが動かなくなる可能性があります。そしてそれはここからは 見えません。自分で書くぶんには問題なく動きます——中の img src は他と同じように変換され、 source 要素には手が入りません。

バイナリに入れるべきでないファイル

Section titled “バイナリに入れるべきでないファイル”

public/ は実行ファイルにコンパイルされます。これは既定として正しくて——成果物が一つ、隣に 配るものが無く、パスを間違えようがない——そして最初の動画あたりで正しくなくなります。200MB の バイナリはビルドも遅く、転送も遅く、ファンクションホストでは単に受け付けてもらえません。

なので authored なツリーが二つあります。

public/ → 変換され、バイナリに埋め込まれる
public-external/ → 何もされず、バイナリの隣に配られる

どちらも同じマウントで応答します。public-external/promo.mp4/public/promo.mp4 として 配信され、ページ側にはどちらのツリーが答えたか出ません。

ここに置くのは、大きくて既に圧縮済みのものだけです——動画、音声、アーカイブ、大きな PDF。 それ以外は public/ に残してください。スタイルシートやアイコンをこちらに出すと、変換も事前 圧縮の兄弟も失って、得るものが無いうえに配るファイルが一つ増えます。pw doctor は埋め込み 側にある 4MiB 超のメディアファイルを報告しますが (PW0132)、 移動はしません。ファイルが育ったからといって置き場所が変わるべきではないので。

ビルドはこれらのファイルをコピーしません。何も変換しないのでステージングのコピーはソースと 同一になり、しかもプロジェクト中で最もコピーしたくないファイルたちだからです。読むのは検証の ための先頭バイトと、メディアタイプを決めるための拡張子だけ。

配信は http.ServeContent を通ります。つまり Accept-Ranges206 レスポンスと If-Range が付きます。埋め込み側には無いものです。分ける価値の本体はここで、シークできない <video> は途中から再生するためにファイル全体をダウンロードします。

代わりに諦めるのが strong な ETag です。このツリーは独立した成果物としてデプロイされるので、 ビルド時に計算した検証子は、それが記述しているバイト列より長生きしうる。だから size と更新 時刻で revalidate します。独立デプロイと immutable な URL は同時に持てず、この仕組みはその 取引そのものです。

pw が作る成果物はすべてこのディレクトリを運びます。しかも必ず、サーバが解決するのと同じ 位置に置きます。

  • スキャフォールドされる Dockerfile はイメージにコピーします。pw initpublic-external/.keep を書くのはそのためで、存在しないパスへの COPY はイメージビルドを 失敗させるからです
  • pw build --target はデプロイステージの config.prod.toml の隣に置きます。Lambda、 Azure Functions、Cloud Run functions、Vercel すべてで同じです。どちらもファンクションが 持つ作業ディレクトリから解決されるので、同じ場所にあるべきものです

自分で手を動かす必要があるのは素のバイナリを配る場合だけです。ディレクトリをバイナリの隣に 置き、public-external/ が解決できる作業ディレクトリでプロセスを起動してください。

ファイルシステムを持たないターゲットはそもそも運べません。Cloudflare Workers がそれですが、 いずれにせよ対応ターゲットではありません。対応済みの ターゲットはすべてコンテナかバンドルとして出ます。

そしてここがこのツリーをコピーする唯一の場所です。pw build はコピーしません。コンパイルの たびにではなく、デプロイ成果物ごとに一度だけ、というわけです。

両方のツリーが同じ URL を持っていたら

Section titled “両方のツリーが同じ URL を持っていたら”

外部側が勝ち、ビルドが警告します。

asset: warning: public-external/app.css (shadows public/app.css)

優先順位が決まっている以上エラーではなく警告です。ただし埋め込み側のファイルはそこに残った まま、配信されているファイルの顔をしています。ビルド出力一行ぶんの価値があるのはまさにその 紛らわしさです。

ファイルは名乗ったとおりのものでなければならない

Section titled “ファイルは名乗ったとおりのものでなければならない”

ここまでの処理はすべて拡張子から決まっています。.png が WebP 変換を選び、マニフェストの メディアタイプも同じところから来て、レスポンスはそのメディアタイプを送る。つまり中身が名前と 食い違うファイルは、名前のほうでラベルを貼られ、バイト列が一度も持ったことのない型を全レスポ ンスが主張することになります。挙げておく価値があるのは、.png の正体が <script> 入りの SVG だった場合です。image/png として配信され、そして拡張子がもう一度信用される場所では、 まったく別のものになります。

pw build はこれを拒否します。

public assets: logo.png: the extension declares png, and the bytes carry no png signature; rename the file to the type it actually is, or list the path in assets.verify.allow

読むのは authored な各ファイルの先頭 64 バイトだけ。ダイジェストを取るためにビルドが既に 持っているバイトなので、実測できるコストはありません。シグネチャを持つ形式は、それを実際に 持っていなければならない。シグネチャを持たない形式——CSS、JavaScript、JSON、SVG——は、 他人のシグネチャを持っていてはならない。後半が ZIP 入りの .css を捕まえる規則で、これが 無ければ、ブラウザが実行可能として扱う拡張子だけがちょうど検査を免れます。テーブルが知らない 拡張子は推測せずに放置します。誰も教えていない名前でビルドを落とさないためです。

検査するのは authored なツリーだけです。ビルドが作ったものはビルドがラベルを貼っている。 この区別は見た目より効いていて、AVIF 表現は意図的に .webp の URL の下に置かれるので、 URL を読む検査はビルド自身の出力を拒否することになります。

テキスト形式のパースはしません。壊れた XML が入った .svg は出荷されます。形式ごとにパーサ を抱えるのは、ラベルが間違っているのではなく単に壊れているファイルを捕まえる、という狭い利得 に対して大きすぎる面積だからです。

SVG は XML で、自分のオリジンから image/svg+xml として配信されたものは、直接開かれると スクリプトが動きます。<img> では元々動きません。だから危ないのは、アセットの URL を誰かが 開くときと、<object> がそれを指しているときです。

対策は二つあり、効いているのはヘッダのほうです。image/svg+xml のレスポンスにはすべて Content-Security-Policy: sandbox が付きます。ドキュメントはユニークオリジンに置かれ、 スクリプトは無効になる。実行される SVG であっても、アプリケーションには届きません。ビルドが その中身を読んだかどうかに関係なく効きます。アプリケーション自身のポリシーは置き換えず、 その隣に足されます。ブラウザは両方を適用するので、宣言した内容を締める方向にしか働きません。

ビルドも authored な SVG を走査して、<scripton…= ハンドラ、javascript: を見つけ たら拒否します。この走査は意図的にリテラル一致です。パースをしないので、SMIL やエンティティ 符号化や名前空間接頭辞の裏に隠れたハンドラは見逃します。ただしそれは防御の穴ではなく警告の 穴です。ヘッダのほうはビルドがそのファイルを理解できたかどうかに依存していないので、頼るべき なのはそちらだ、というわけです。

sandbox を切ってよいのは、<object> やリンク経由で意図的にインタラクティブな SVG を 配信している場合だけです。スイッチは server.public.svg_sandbox = false で、このエンド ポイントが配信する全 SVG に効きます。ヘッダを手放さずに特定のファイルだけ通したいなら、 assets.verify.allow のほうが細い道具です。そのパスをビルド側の二つの検査から外すだけで、 レスポンスは何も変わりません。

ビルドは変換のに、圧縮対象のファイルへ .br.zstd.gz の兄弟を書きます。 圧縮されるのは実際に出荷されるバイト列だ、というわけです。配信時の CPU コストはゼロに なります。符号化済みのバイト列がもう存在しているのですから。

圧縮する しない
.html, .css, .js, .mjs, .json, .map, .txt, .xml, .svg, .webmanifest, その他の text/* SVG 以外の画像、音声、動画、フォント、アーカイブ、WebAssembly——すでに圧縮されているもの

3 つとも最大レベルで走ります。ここでそれが許されるのは、レンダリングしたレスポンスでは 許されないのと同じ理由です——代償がリクエストではなくビルドに落ちる。brotli がここにだけ 存在するのもそのためで、最大レベルなら zstd より約 15%、gzip より約 17% 小さくなります。 クライアントを待たせながら符号化するには遅すぎるレベルで、ようやく現れる差です。

結果が元より小さくならなかったコーディングは、書かずに飛ばされます。短いファイルなら 兄弟が 3 つ未満だったり、ひとつも無かったりする。それは普通のことです。ネゴシエーションは 次のコーディングへ落ち、identity のバイト列はいつでも答えられます。

サイドカーのパスが URL になることはありません。隣のアセットの表現であり、そこへのリクエストは 404 です。それぞれが自分のバイト列の検証子を持つので、ひとつを保存したキャッシュが別のものを 求めたクライアントにそれを渡すことはできません。

挙動 内容
メソッド GETHEAD。それ以外は Allow 付きの 405
末尾スラッシュ無しのマウント マウントへの 308。クエリ文字列は維持
エンコーディング br, zstd, gzip のうち、Accept-Encoding が q 値 0 超で許しかつサイドカーがある最初のもの。無ければ identity。順序はビルドのもの——小さい順であって、クライアントの q 値ではありません
メディアタイプ リクエストが受け入れる中で最も優先されるもの。無ければフォールバック
Vary Accept-Encoding。メディアタイプが複数ある URL では Accept
ETag strong。ビルドが決めたもので、表現ごとに別
If-None-Match 一致すれば 304
リビジョンセグメント 剥がしてマニフェストと照合。一致すれば immutable、このビルドが配信しないセグメントは 404
すべて拒否された 406

ディレクトリは index.html があればそれとして解決され、無ければ 404 です。ディレクトリ 一覧は存在しません。

ここの規則はすべて拒否であり、例外が無いという意味で知っておく価値があります。

  • ドットで始まるセグメントは拒否されます。public/ 配下の .env.git/config には 到達できません
  • トラバーサル、バックスラッシュ、NUL は、パーセントデコードを 1 回行ったあとで拒否されます
  • シンボリックリンクは拒否されます——ローカルのルート、その配下、通常ファイル以外のすべて。 ビルドも walk を拒否するので、リンク先が埋め込まれることはありません
  • リクエストパスの .br, .zstd, .gz 接尾辞はその場で拒否されます

pw dev は同じ変換を走らせ、dist/public をディスクから配信します。 アセットを編集してリロードすれば、ツリーが作り直されます。バイナリがコンパイルしない ファイルのために Go のリビルドが走ることはありません。マニフェストが無いので AssetURL は素の URL を 返し、編集は次のロードで見えます——毎リクエスト再検証されるのが望ましい唯一の場所です。

変換をスキップせず同じものを走らせるのには理由があります。書き換えられた参照は生成コードに 焼き込まれるので、スキップした開発ビルドは自分が作っていないファイルを指すページを 配信することになります。それを現実的なコストに収めているのが dist/ 配下の変換キャッシュ です。変わっていないアセットにかかるのはダイジェスト計算だけで、エンコードは走りません。 支払いが発生するのは clone 直後の初回ビルドです。

public-external/ はプロジェクトルートから直接読まれ、しかもビルド済みツリーより先に 参照されます。本番と同じ優先順位です。この順序は見た目より効いていて、ループ側で後回しに すると、意図的にシャドウしたファイルがここでは一方、デプロイ後にはもう一方で表示されること になります。

このループの外では、server.public.read_local = true がふつうのビルドでも同じディスク優先の 挙動を与えます。埋め込みツリーにディレクトリを重ねるデプロイのためのスイッチです。

キー 既定値 意味
server.public.enabled true そもそも配信するか
server.public.mount "/public" マウント先
server.public.read_local false 埋め込みツリーよりディスク上のビルド済みツリーを優先する
server.public.svg_sandbox true image/svg+xml のレスポンスに Content-Security-Policy: sandbox を付ける

マウントは絶対パスかつ正規形で、ルートではなく、ワイルドカードを含まないこと。ここと衝突する アプリケーションのルートは、マウントを覆い隠すのではなく起動を失敗させます。enabled を 切ってもルートが登録されなくなるだけで、バイナリは何も変わりません。アセットは埋め込まれた まま、到達できなくなるだけです。

ビルド時のキーはビルドツール設定一覧にあります。dist/ は ビルド成果物です。スキャフォールドされる .gitignore は、初回ビルド前でも embed が通るための 番人ファイルを除いて、その配下すべてを除外します。

フレームワーク自身のブラウザ向けスクリプトはここには入りません。アプリケーションのルーティング より手前の固定パス /_pw/ から配信されます。だからこのエンドポイントをどう設定しても 使えなくなりません。非同期レンダリングを参照して ください。

上のサイドカーは静的なファイルで、ビルド時に一度だけ圧縮されます。アプリケーションがたった今 レンダリングしたレスポンスを圧縮するのは別のスイッチで、トレードオフも別です。 レスポンス圧縮を参照してください。あちらのミドルウェアが、 このハンドラの返したものを再圧縮することはありません。

提供するコーディングも違い、レベルが違うのと同じ理由によります。レンダリングした レスポンスはクライアントを待たせながら符号化されるので、あちらが提供するのは zstdgzip だけで、しかも浅く走ります。brotli と最大レベルはこちらに残ります。ここでは誰も 待っていないからです。