---
slug: "sveltekit-adapter-node-static-cache-control-header"
title: "SvelteKit を adapter-node で Docker コンテナとして動かしている時、static 以下の静的ファイル配信にクライアントキャッシュ用レスポンスヘッダーを付与する"
description: "SvelteKit の adapter-node は static/ ディレクトリのファイルに Cache-Control ヘッダーを付けない。Node.js の --import フックで http.ServerResponse.prototype.writeHead をパッチし、特定パスのレスポンスにキャッシュヘッダーを付与する方法を解説する。"
url: "https://www.ytyng.com/blog/sveltekit-adapter-node-static-cache-control-header"
publish_date: "2026-03-01T23:35:10Z"
created: "2026-03-01T23:35:10.974Z"
updated: "2026-03-02T00:49:19.373Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260301/028e821db5a4408390ec8db6b6e8c9fb.png.webp?width=768"
has_video: true
has_music: true
video_urls: ["https://media.ytyng.net/ytyng-blog/337/featured-video-1.mp4", "https://media.ytyng.net/ytyng-blog/337/featured-video-2.mp4", "https://media.ytyng.net/ytyng-blog/337/featured-video-3.mp4"]
music_urls: ["https://media.ytyng.net/ytyng-blog/337/featured-music-337-3.mp3", "https://media.ytyng.net/ytyng-blog/337/featured-music-337-4.mp3"]
lang: "ja"
---

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

## 背景

SvelteKit を `@sveltejs/adapter-node` でビルドし、Docker コンテナとして本番運用している環境で、`static/` ディレクトリに配置した画像ファイル（`/images/hero.jpg` など）や `favicon.png` に対して、ブラウザキャッシュを効かせたい。

## 問題

adapter-node は内部で [sirv](https://github.com/lukeed/sirv) を使って静的ファイルを配信している。ビルド生成物である `_app/immutable/` 以下のハッシュ付きアセット（JS/CSS）には自動的に `Cache-Control: public, max-age=31536000, immutable` が設定されるが、**`static/` ディレクトリのファイルには `Cache-Control` ヘッダーが設定されない。**

具体的には、adapter-node の `handler.js` 内で sirv を呼ぶ際に `maxAge` オプションが渡されておらず、`setHeaders` コールバックも `_app/immutable/` パスにのみ反応するようになっている。

```js
// 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 に依存するよりも明示的な方が安全。）

```js
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 の変更

```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 の内部実装に依存せず、アップデートにも強い。キャッシュ対象パスの追加も配列に要素を追加するだけで済む。
