コンテンツにスキップ

Web標準

「標準準拠」と書かれていても、実際の選択がすべてアプリケーション任せなら、通信相手から 見える挙動は揃いません。ここでは、Popcorn Web が実際に送信または検証する内容だけを 挙げ、詳しい説明があるガイドへ案内します。RFC 番号は公開仕様を表します。一方、 X-RateLimit-* は互換性のために広く使われている慣用的なヘッダーであり、IETF 標準ではありません。 公開されているかどうかだけが基準でもありません。ひとつのエンジンだけが実装した仕様と、どのエンジンも 実装した仕様は別物です。そして、どのサーバーからも送れるがどのブラウザも読まない仕様は、また別物です。 最後の2節は、その差によって採用を見送った機能を扱います。

セキュリティヘッダーとブラウザ境界

Section titled “セキュリティヘッダーとブラウザ境界”

セキュリティミドルウェアがCSP、HSTS、X-Content-Type-OptionsX-Frame-OptionsReferrer-PolicyPermissions-Policyを管理します。CSRF対策は セッションに結び付いたトークンとOrigin検証を組み合わせ、CookieポリシーがSecureHttpOnlySameSite、署名、暗号化を管理します。

script-src 'self'でインラインスクリプトを禁止すると、テンプレートに直接書いたJavaScriptと 衝突するように見えます。生成時にその衝突をなくします。.pw.html内に本文を持つ<script>は レスポンスへそのまま出さず、コンポーネントごとのハッシュ付きJSファイルへ切り出します。統合 されたheadには<script src="…">を置き、deferasynctypeなど著者が指定した属性も 残します。ブラウザが実行するのは同一オリジンの外部スクリプトなので、nonceも'unsafe-inline' も要りません。

高速なテンプレート描画のために、このポリシーを'unsafe-eval'で緩める必要もありません。 pw generateはテンプレート式と制御構文を_pw_gen.goへコンパイルし、サーバーではGoとして ビルドされた関数を実行します。実行時のテンプレートインタプリタも、ブラウザへ送るテンプレート コンパイラも、evalも描画経路に入りません。生成されたGoによる高速な描画と、狭いCSPを同時に 維持できます。

クロスオリジンのアクセスは、同じ枠のもう半分です。CORSはRFCではなくWHATWG Fetch Standardに 属し、フレームワークはそのサーバー側を実装しています。preflightに答えるのはセッション・認証・ ガードより先です。preflightはそれらの枠が探す資格情報を何ひとつ携えていないからです。そして レスポンスへの印は、リクエストを拒否しうる枠すべてより先に付きます。価値があるのは後半です。 印のないクロスオリジンレスポンスは、ブラウザがボディだけでなくステータスも渡しません。印が なければ、このフレームワークが書く型付きの拒否——401403、リトライ情報を携えた429——は すべて、呼び出し元には区別のつかない1種類のネットワークエラーとして届きます。

意図的に外にあるものが3つあります。オリジンのパターン言語はありません。許可するオリジンは 完全一致のscheme://host[:port]か、単独のワイルドカードだけです。リクエストごとに評価される パターンは間違えられる場所がもう1つ増えることであり、その間違いは静かだからです。Private Network AccessはChromium限定の交渉なので実装しません。そしてWebSocketのアップグレードは そもそもCORSの対象外——preflightなしでオリジンをまたいでCookieを運びます——なので、防御は ここで設定する何かではなくOrigin検証です。

認証を有効にするだけでは、どの方式が使われるかは決まりません。Popcorn Webはブラウザの ログインとBearer APIの信頼モデルを分け、次の5つから1つを選びます。

構成 設定 用途 認証のしかた
OIDC oidc_only ブラウザ OpenID Providerだけでログインする
OIDC + パスキー oidc_passkey ブラウザ OIDCでアカウントを確立し、普段はWebAuthnパスキーでログインする
パスキー passkey_only ブラウザ 外部プロバイダを使わず、管理者発行の使い捨て資格情報で最初のパスキーを登録する
JWT jwt_only API リクエストごとのBearerアクセストークンを検証する。セッションやログイン画面は持たない
なし auth.enabled = false 公開アプリケーション、または独自認証 認証エンドポイントも認証ガードも組み込まない

パスワードは6つ目のモードではありません。フレームワークはパスワードの入力、保存、照合、 再設定を扱わず、漏えいしたIDとパスワードの組を順番に試すリスト型攻撃の入口を作りません。 パスキーだけのモードで最初に使うシークレットも、再利用できるパスワードではなく、登録を 1回だけ許可する期限付きの資格情報です。OIDCの先にあるプロバイダが利用者をどう認証するかは プロバイダの責務であり、アプリケーションがそのパスワードを受け取ることはありません。

OIDCクライアントは2つの経路に対応します。ブラウザログインにはAuthorization Code Flowを 使います。認可コードの横取りだけでログインを完了できないよう、PKCEはS256で常に有効になり、 無効化する設定はありません。さらにstateを単回限りの認証トランザクションへ結び付け、 nonceを必須にして、返されたID Tokenがそのブラウザで開始したログインに対応することまで 検証します。TinyGoの組み込み機器など、ブラウザや入力手段に制約があるクライアントには RFC 8628 Device Authorization Grantを使えます。 機器はuser codeとverification URIを表示し、利用者が別のブラウザで承認するまでtokenを pollします。OIDCラッパーはopenidを要求し、返されたID Tokenのissuer、audience、署名、 有効期間、subjectを同じ基準で検証します。

Resource Owner Password Credentials Grant(Password Grant)には対応しません。 現在のOAuth 2.0 Security Best Current Practice(RFC 9700 2.4節)が、 利用者のパスワードをclientへ渡してしまい、多段階の認証にも適合しないことから MUST NOTと定めているためです。Client Credentials Grantも 意図的に対象外です。これはend-userのidentityを伴わず、client自身を認可する machine-to-machineまたはバッチ処理向けで、利用者のログインとsessionを扱う本システムの 認証境界には適しません。Device Authorization Grantとは別の方式です。

JWTモードも「署名が合えば通す」だけではありません。許可したアルゴリズムと取得した鍵で 署名を検証してから、issaudexpiatsub、アクセストークンの種別、最大有効 期間、必須scopeを検査します。必要な構成ではjtiと失効状態も検査します。issuer、audience、 アルゴリズム、有効期間の上限、admission、失効モードに寛容な既定値はなく、不足すれば起動を 拒否します。どの検査で失敗しても外部には同じ401を返すため、拒否理由が探索の手掛かりに なることもありません。

開発環境でも同じ認証境界を通す

Section titled “開発環境でも同じ認証境界を通す”

pw devは、正規のOpenID Providerと同じOIDCプロトコルを話す簡易IdPをループバック上に 起動できます。実行ごとに一時クライアントを登録し、issuer、client ID、client secretを アプリケーションへ注入するので、本番コードにデバッグ用ログインや認証を迂回する分岐を 追加せず、Authorization Code、PKCE、statenonce、ID Token検証を含む同じ経路を 試せます。公開client、user code、polling、ブラウザでの明示的な承認を含むDevice Authorizationも実装しています。簡易IdP自身はパスワードを検証せず、一覧から開発用ユーザーを 選びます。開発以外では起動せず、通常のビルド成果物へ混入することも拒否されます。

JWTには、ローカルから手書きのトークンでAPIを試すための開発専用緩和があります。有効に すると署名、issuer、audience、token type、時刻、アルゴリズム許可リストの検証をまとめて 省略します。ただし、開発用ビルド、開発環境、明示設定、プロキシを経由しないループバック リクエストという4つの条件がすべて必要です。identity claim、admission、失効、トークンの 構文とサイズ上限は省略されず、緩和を使ったレスポンスにはX-Pw-Auth-Unverified: trueが 付きます。本番用バイナリは、この緩和設定を黙って無視するのではなく起動時に拒否します。

エラーレスポンスとレート制限

Section titled “エラーレスポンスとレート制限”

pw.WriteProblemはRFC 9457 Problem Detailsと安全なHTMLエラーページをネゴシエーション します。HTTP 429はRFC 6585に従い、標準のRetry-Afterを付けられます。さらに、既存の クライアントとの互換性のため、X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Resetも利用できます。ただし、こちらはIETF標準のフィールドではありません。

非推奨と停止予定は近い概念ですが、同じ意味ではありません。RFC 9745のDeprecationは、 リソースの挙動を変えずに、いつから利用を推奨しないかを伝えます。RFC 8594のSunsetは、 いつ利用できなくなる見込みかを伝えます。pw.LifecycleHeadersは両方の日付をひとつの値で 受け取り、前後関係を検証してから、それぞれのRFCが定める形式へ変換します。

lifecycle, err := pw.LifecycleHeaders(pw.Lifecycle{
DeprecatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
SunsetAt: time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC),
DocumentationURL: "https://example.com/migrations/v2",
})
if err != nil {
log.Fatal(err)
}
router.Handle("GET /v1/items", lifecycle(http.HandlerFunc(listV1Items)))

レスポンスのDeprecationにはStructured Field Date、SunsetにはHTTP-dateが入り、移行文書は Link関係として示されます。このミドルウェアはライフサイクルを通知するだけで、レスポンスの 意味を変えたり、期限に達したルートを自動停止したりはしません。

pw generateは、登録済みハンドラ、リクエストバインディング、レスポンスライター、ストリーム、 ProblemコンストラクタからOpenAPI 3.1の操作を導出します。生成された文書と、任意のScalarまたは Swagger UIは、アプリケーションルートと同じパスガードの内側で運用エンドポイントとして提供されます。

文書そのものは、クロスオリジンのポリシーを設定していてもいなくても Access-Control-Allow-Origin: *を返します。公開すると既に決めた契約の記述であり、それを読む ツール——別の場所にホストしたドキュメントUI、クライアントジェネレータ、CIのリンタ——の オリジンは事前に列挙できないからです。ワイルドカードは資格情報を禁じるので、パスガードの 内側に置いた文書でも、クロスオリジンの読み手には未認証時の応答が返ります。

キャッシュとコンテンツネゴシエーション

Section titled “キャッシュとコンテンツネゴシエーション”

HTMLは、ドキュメントシェルが公開スコープを宣言しない限りprivate, no-storeです。フィンガー プリント付きアセットはvalidatorとimmutableキャッシュを使い、ナビゲーション差分とライブ配信は no-storeを使います。429レスポンスも保存されません。

表現形式はHTTPフィールドに従う

Section titled “表現形式はHTTPフィールドに従う”

同じ操作でも、扱いやすい通信形式は呼び出し元によって変わります。JavaScriptから送るならJSONが 簡単です。一方、JavaScriptなしでも動くHTMLフォームや、JSON文書を組み立てず手早く入力したい curlではフォーム形式が自然です。そこでPopcorn Webは、クライアントの種類ごとに別のハンドラを 要求しません。同じリクエスト構造体でapplication/jsonapplication/x-www-form-urlencodedmultipart/form-dataを受け、リクエストの Content-Typeに従ってデコードします。

レスポンスでは、これと対になるAcceptが、提供できる表現から形式を選びます。たとえば同じ型付き ストリームを、ハンドラを分けずにSSE、NDJSON、JSON配列として返せます。Problem Detailsも、安全な HTMLページとJSONのどちらを返すかをネゴシエーションします。application/ld+jsonのような通常の メディアタイプも、別のRPCトランスポートを増やさずアプリケーションから追加できます。フレームワークが 境界でContent-TypeAccept、関連するHTTPフィールドを読み、アプリケーションコードは値を記述し、 通信表現は周囲に合わせる設計です。

同じ画像URLでも、クライアントが扱える形式は同じとは限りません。画像変換を有効にすると、 img srcが指すPNGとJPEGをビルド時にWebPへ変換します。assets.images.avif = trueも有効なら、 同じ入力からAVIF表現も作り、1つのURLにWebPとAVIFの両方を持たせます。変換後のほうが元画像 より大きい場合はその変換を採用せず、元のPNGまたはJPEGを残します。

WebPはBaseline Widely Availableで、現在のブラウザではフォールバックとして使える範囲に 達しています。AVIFは対応が広がった時期が新しく、古いブラウザ、WebView、ブラウザ以外の クライアントには非対応が残りえます。そこでUser-Agentから能力を推測しません。リクエストの Acceptimage/avifを許し、AVIF表現が存在するときはAVIFを返し、それ以外はWebPへ戻ります。 WebP変換を採用しなかったURLでは、元のPNGまたはJPEGがフォールバックです。表現が2つある URLだけにVary: Acceptを付けるので、共有キャッシュもクライアントごとの表現を混同しません。

静的アセットはビルド時に深く圧縮する

Section titled “静的アセットはビルド時に深く圧縮する”

対応するアセット変換を有効にすると、public以下のCSSと著者が書いたJavaScriptをminify します。script srcが指すTypeScriptとTSXはesbuildで依存先を含むESモジュールへバンドルし、 minifyしたハッシュ付きファイルへ書き換えます。その変換が終わってから、HTML、CSS、 JavaScript、JSON、SVGなど実際に出荷するバイト列をBrotli、zstd、gzipの最大レベルで事前 圧縮し、.br.zstd.gzのサイドカーを作ります。最大レベルのCPUコストを払うのは ビルドです。リクエストはすでにあるバイト列を返すだけなので、配信時の圧縮CPUは要りません。

配信時はAccept-Encodingが許すサイドカーから最も小さいものを選び、どれも許されなければ identityを返します。各表現には別のETagがあり、Vary: Accept-Encodingがキャッシュの混同を 防ぎます。

動的レスポンスは浅く速く圧縮する

Section titled “動的レスポンスは浅く速く圧縮する”

ハンドラがその場で生成するHTML、JSON、ナビゲーション差分、ライブ配信には、待っている クライアントがいます。レスポンス圧縮を有効にすると、対象となるレスポンスでは Accept-Encodingに応じてzstdまたはgzipを選び、zstdは最速設定、gzipはlevel 1で実行します。 Brotliと最大圧縮を使わないのは、数バイトを追加で減らす代わりにレスポンスを待たせないため です。同じ圧縮でも、静的アセットでは転送量を、動的レスポンスではスループットを優先します。

W3C Trace Context は RFC ではなく Recommendation ですが、フレームワークはその両側に 立っています。traceparenttracestate は入ってくるリクエストすべてから読まれ、 計装済みクライアント経由で出ていくリクエストすべてに書かれます。だからトレースは サービスの境界で切れずに続きます。2つの HTTP バックエンドは同じバリデータを通して これらを読みます。以下の規則が片方でだけ適用される事態を防いでいるのがその共有です。

要点は、壊れたヘッダーが何を失わせるかです。形式が不正な traceparent、禁止された ff バージョン、大文字で綴られた識別子、そして2つ以上届いた場合——いずれもその リクエストは親を持ちません。新しいトレースの根になり、リクエスト自体は通常どおり 処理されます。文法に通らない tracestate は単体で捨てられ、親は残ります。これは仕様が 求めている振る舞いで、トレースはつながったまま、失われるのはベンダーデータだけです。

ヘルス、レディネス、OpenAPI、APIドキュメントの各エンドポイントには、異なる可用性とアクセス 規則があります。リクエストID、ボディ制限、タイムアウト、パニック回復、圧縮、リダイレクト、 Graceful Shutdownが、アプリケーションハンドラの外側にあるHTTP境界を完成させます。

公開するWebアプリケーションにHTTPSは必要です。それでもPopcorn Webは、証明書と秘密鍵を 読み込んでアプリケーション自身がTLSを終端する機能を、あえて実装していません。TinyGoでは crypto/tlsの実装に制約があり、通常のGoと同じサーバー機能を持たせるのが難しい。ただし、 理由はコンパイラだけではありません。

実装しないのは、外部から接続を受けるHTTPSサーバーです。アプリケーションがOIDCや外部APIの クライアントになるときのHTTPSは、両方のビルドで利用できます。ホスト版Goはnet/httpcrypto/tlsをそのまま使い、TinyGoはtinygodriver/httpsへ切り替わります。TinyGo版のTLSは、 macOSではNetwork.framework、WindowsではSchannelというOS内蔵のレイヤーを使い、Linuxでは 同梱したmbedTLSを使います。外向き通信まで平文にする設計ではありません。

TLSをアプリケーションへ持ち込むと、証明書の発行、更新、失効、秘密鍵の保護、暗号設定の更新 までがアプリケーションの運用責任になります。さらに接続を終端するプロセスは、TLSハンド シェイクや大量接続を含む攻撃トラフィックを最初に受けます。CDNやマネージドロードバランサ なら、証明書のライフサイクルとDDoS緩和を、より大きなネットワークの入口で引き受けられます。 HTTPSを省いたのではなく、終端する場所を前段へ移したという設計です。

本番では、CDNまたはロードバランサでHTTPSを終端し、オリジンのアプリケーションポートへ 外部から直接到達できないようにしてください。CDNからオリジンまでが信頼できないネットワーク を通るなら、アプリケーションと同じホストまたはプライベートネットワークにTLSプロキシを置き、 そこまでHTTPSを維持します。アプリケーションが受け取る最後の区間だけを、到達範囲を制限した HTTPにします。

ローカルでもHTTPSが必要なら、nginx、Caddy、Traefikなどをアプリケーションの前に置く方法を 推奨します。証明書とTLS設定はその層に任せ、Popcorn WebにはHostX-Forwarded-Protoを正しく渡します。信頼するプロキシの範囲、HSTS、CSRFの公開オリジンは リバースプロキシの後ろに置くで設定します。

クライアントヒントを採用しない理由

Section titled “クライアントヒントを採用しない理由”

Accept-CHは、読み手が望む状態のページを最初から返すための正攻法に見えます。サーバーが欲しいヒントを 広告し、ブラウザが次のリクエストでSec-CH-Prefers-Color-Schemeを送れば、ダークを好む読み手には最初の 描画からダークが届く。スクリプトは要らず、切り替わりのちらつきも起きません。それでもPopcorn Webは Accept-CHを送信せず、Sec-CH-*リクエストヘッダーも読みません。この仕組みを実装しているエンジンが ひとつしかないからです。

ヘッダー Chromium Firefox Safari
Sec-CH-Prefers-Color-Scheme 93
Sec-CH-Prefers-Reduced-Motion 108
Sec-CH-Viewport-Width 97
Critical-CH 91

いずれも実験的機能の扱いのままです。初回ナビゲーションを再送させてヒントを間に合わせるCritical-CHに 至っては、標準化トラックにも乗っていません。これらに依存したアプリケーションは、ChromeとEdgeでは正しく 描画され、それ以外では推測に落ちます。フレームワークがヘルパーを用意しても、この差は埋まりません。狭い 経路が推奨経路に見えるようになるだけです。

決め手は、代替手段のほうが優れていることでした。CSSのprefers-color-schemeはOSの設定を最初の描画より 前に反映し、どのブラウザでも動き、往復も増やさず、キャッシュも痛めません。そうなるとヒントが改善できる のは「読み手がこのサイトで明示的に上書きした場合」だけですが、その選択はCookieならどのブラウザでも 運べます。ビューポート幅はさらに分が悪い。幅は端末を回せば変わり、それを知らせるナビゲーションは発生 しないので、サーバー側の判断は読み手が画面を見ている最中に古くなります。コンテナクエリとsrcsetは要素 ごとに判断し、古くなりません。

議論を終わらせるのはキャッシュです。配色でVaryするレスポンスは表現が2つに増えるだけで、これは払える 代償です。ビューポート幅でVaryすると、ウィンドウ幅の種類だけ表現が増えます。共有キャッシュはほぼ全 リクエストで外れ、ヒントで得たものより失うもののほうが大きくなります。

禁止しているわけではありません。ハンドラが自分でヘッダーを読み、対応するVaryを設定することはできます。 ちらつきを実測し、キャッシュの代償を承知のうえでそうするなら、それはアプリケーションが持つべき判断です。 フレームワークはその判断を代行しない、という立場をとっています。

Server-Timingトレーラーを採用しない理由

Section titled “Server-Timingトレーラーを採用しない理由”

pw devは、レスポンスの裏側で起きたことをすでに計測しています。トレーシングがレンダリングにひとつ、 確定したバウンダリごとにひとつ、実行したステートメントごとにひとつスパンを開くので、遅いページの内訳は 誰かが探しに行く前から存在しています。それを同じレスポンスに載せて返すのは自然な次の一手に見えますし、 HTTPには仕組みもあります。Trailer: Server-Timingは、ボディを送り終えてからでないと確定しない値を 運べる。ストリーミングするページでは、知りたいことのほとんどがそこに入ります。ブラウザはそれを リクエストの隣に表示してくれる。それでもPopcorn Webはトレーラーを送りません。pw devが張る接続では、 それを読むものが存在しないからです。

Chromium Firefox Safari
レスポンスヘッダーとしてのServer-Timing
トレーラーとしてのServer-Timing HTTPSのみ
fetch()から読むトレーラー全般

トレーラーを何らかの形で露出したエンジンは、これまでFirefoxだけです。そのFirefoxも対応をHTTPSに限定し、 トレーラー対応を広告するのはHTTP/2の上だけ、そしてHTTP/2を喋る相手はhttps://のオリジンだけでした。 Chromiumは繰り返し見送っています。ブラウザ内でネットワークリクエストに介入する箇所が多すぎて、変更が 収まらないという理由です。そしてpw devが待ち受けるのは、平文のHTTP/1.1ポートです。Chromeはトレーラーを 無視し、Firefoxは「HTTPSではない」として受け取らない。開発者がそのために開いたパネルは、どちらでも 空のままになります。

迂回路がないことは、表の3行目が示しています。fetch()はそもそもトレーラーを読めません。読めるのは DevToolsだけで、それもServer-Timingに限った特別扱いとしてネットワークパネルに配線されているだけです。 ページ上のスクリプトも、開発コンソールのペインも、トレーラーを拾って描画することはできない。残る消費者は ひとつきりで、しかもそれを表示しないエンジンが3つのうち2つあります。

開発時にchunked encodingを強制する、というのが提案のもう半分でした。運ぶはずだったトレーラーが消えれば、 こちらも一緒に落ちます。単独でも代償はありました。開発サーバーがデプロイ先と違うフレーミングでレスポンスを 返すなら、それはもうデプロイ先の予行演習ではありません。このフレームワークは一度その代償を払っています。 開発中は一度も現れなかったバウンダリマーカーのバグが、プロキシ・TLSレコード・圧縮エンコーダのいずれかが 開発では起きない位置でバイト列を分割した途端、本番で表面化しました。

決め手は、計測結果が失われるわけではないことでした。同じスパンは 開発テレメトリビューアにツリーとして並び、同一リクエストの ログレコードやステートメントと相関が取れています。レスポンスをコミットしたあとに起きたことも、そこには 含まれる。レスポンスヘッダーでは運べない部分であり、トレーラーが提案された理由そのものでもありました。

こちらも禁止はしていません。ハンドラがServer-Timingを通常のレスポンスヘッダーとして設定すれば、どの ブラウザのDevToolsでも初回バイトまでの内訳は読めます。その隣にトレーラーを書くこともできる。できないのは、 そのトレーラーが読まれることを期待することだけです。