SvelteKit を adapter-node で Docker コンテナとして動かしている時、static 以下の静的ファイル配信にクライアントキャッシュ用レスポンスヘッダーを付与する

背景
SvelteKit を @sveltejs/adapter-node でビルドし、Docker コンテナとして本番運用している環境で、static/ ディレクトリに配置した画像ファイル(/images/hero.jpg など)や favicon.png に対して、ブラウザキャッシュを効かせたい。
問題
adapter-node は内部で sirv を使って静的ファイルを配信している。ビルド生成物である _app/immutable/ 以下のハッシュ付きアセット(JS/CSS)には自動的に Cache-Control: public, max-age=31536000, immutable が設定されるが、static/ ディレクトリのファイルには Cache-Control ヘッダーが設定されない。
具体的には、adapter-node の handler.js 内で sirv を呼ぶ際に maxAge オプションが渡されておらず、setHeaders コールバックも _app/immutable/ パスにのみ反応するようになっている。
// adapter-node の handler.js 内部(簡略化)
function serve(path, client = false) {
return sirv(path, {
etag: true,
setHeaders: client
? (res, pathname) => {
if (pathname.startsWith(`/_app/immutable/`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
}
: undefined
});
}
つまり、static/images/hero.jpg にアクセスすると ETag による条件付きリクエスト(304)は効くが、Cache-Control ヘッダーが無いためブラウザは毎回サーバーに問い合わせを行う。
検討した方法と採用した方法
hooks.server.ts で設定する → 不可
SvelteKit の hooks.server.ts の handle 関数は、static/ のファイル配信時には呼ばれない。adapter-node の sirv ミドルウェアが先にレスポンスを返すため。
nginx ingress の server-snippet で設定する → 不可
Kubernetes + nginx ingress controller の環境で、server-snippet アノテーションを使って location ブロックを追加する方法も考えたが、nginx ingress controller v1.9.0 以降では allow-snippet-annotations がデフォルト無効のため、admission webhook に拒否される。
カスタムサーバーエントリポイントを作る → 機能欠落のリスク
adapter-node が生成する build/index.js を置き換えるカスタムサーバーを作る方法。しかし、オリジナルの index.js には graceful shutdown、KEEP_ALIVE_TIMEOUT / HEADERS_TIMEOUT 環境変数対応、socket activation など多くの機能が含まれており、これらを自前で再実装・維持するのはメンテナンスコストが高い。
Node.js の --import フックで writeHead をパッチする → 採用
build/index.js を一切変更せず、Node.js の --import オプションで起動前にグローバルな http.ServerResponse.prototype.writeHead をパッチする方法。オリジナルの全機能を維持しつつ、特定パスにだけキャッシュヘッダーを追加できる。
実装
cache-headers.mjs
拡張子を .mjs にすることで、Docker の本番ステージに package.json がコピーされていなくても、確実に ESM として解釈される。(build/index.js も ESM だが、Node.js 22 の auto-detect に依存するよりも明示的な方が安全。)
import http from 'node:http';
/** 7 days in seconds */
const STATIC_MAX_AGE = 604800;
const CACHE_PREFIXES = ['/images/'];
const CACHE_EXACT = ['/favicon.png'];
const originalWriteHead = http.ServerResponse.prototype.writeHead;
http.ServerResponse.prototype.writeHead = function (statusCode, ...args) {
if (statusCode >= 200 && statusCode < 400) {
const req = this.req;
if (req) {
const pathname = (req.url || '').split('?')[0];
if (
CACHE_PREFIXES.some((p) => pathname.startsWith(p)) ||
CACHE_EXACT.includes(pathname)
) {
if (this.getHeader('Cache-Control') === undefined) {
this.setHeader('Cache-Control', `public, max-age=${STATIC_MAX_AGE}`);
}
}
}
}
return originalWriteHead.call(this, statusCode, ...args);
};
ポイント:
CACHE_PREFIXES: 末尾が/のパターンは前方一致(/images/以下すべて)CACHE_EXACT: 完全一致(/favicon.pngのみ)- ステータスコード 200〜399 のみにキャッシュヘッダーを付与(404 等にはキャッシュを付けない)
getHeader('Cache-Control') === undefinedで既存ヘッダーの上書きを防止。将来 adapter-node や sirv 側で Cache-Control が設定されるようになっても安全writeHeadの呼び出し時にパスを判定するため、sirv が返すレスポンスにも確実に適用される.mjs拡張子によりpackage.jsonの"type"フィールドに依存せず ESM として動作する
Dockerfile の変更
# 静的アセットのキャッシュヘッダー付与フック
COPY docker/cache-headers.mjs ./cache-headers.mjs
# --import フックで静的アセットのキャッシュヘッダーを付与し、SSR サーバーを起動
CMD ["node", "--import", "./cache-headers.mjs", "build/index.js"]
変更は2行だけ。build/index.js はそのまま使うため、adapter-node の graceful shutdown やタイムアウト設定などの全機能が維持される。
動作確認
デプロイ後、ブラウザの開発者ツールで /images/ や /favicon.png のレスポンスヘッダーを確認すると、Cache-Control: public, max-age=604800 が付与されていることがわかる。
まとめ
| 方法 | 評価 |
|---|---|
| hooks.server.ts | static/ のリクエストは通らないため不可 |
| nginx ingress server-snippet | v1.9.0+ でデフォルト無効、admission webhook に拒否される |
| カスタムサーバー (index.js 置き換え) | graceful shutdown 等の機能維持が困難 |
| --import フックで writeHead パッチ | 採用。既存機能を維持しつつ最小限の変更で実現 |
--import フックによるプロトタイプパッチは一見トリッキーだが、adapter-node の内部実装に依存せず、アップデートにも強い。キャッシュ対象パスの追加も配列に要素を追加するだけで済む。
開発相談をお待ちしています。