⚠️

Next.js の Cache Handler で InvariantError になりハマった

に公開

はじめに

Next.js をセルフホストする際、避けて通れないのが Custom Cache Handler の実装です。

Vercel にデプロイすれば勝手にやってくれる ISR や Data Cache の永続化ですが、セルフホスト環境ではデフォルトでメモリキャッシュ(再起動で消える)やファイルシステムキャッシュ(コンテナ間で共有できない)になってしまいます。

これを実装する際に@neshca/cache-handler というライブラリもありますが、repo を見ると分かる通り、メンテが止まっており、プロダクトに取り入れるには少し懸念があります。結局やっていることは、redis にパスなどのキーをもとにしてデータを add/get するだけなのでシンプルだろうと思い、Valkey (Redis)を用いて自前実装を試みました。

すると、「データの型」と「オブジェクトの構造」 という2つの落とし穴に見事にハマりました。

この記事では、InvariantError の原因究明と、GitHub のソースコードに基づいた正しい実装方法(Next v16 対応)を共有します。

遭遇したエラー

以下のドキュメントに沿って、実装を進めました。
https://nextjsjp.org/docs/app/guides/self-hosting#configuring-caching

すると next.config.ts でカスタムハンドラーを指定し、pnpm start で起動。ページにアクセスした瞬間に以下のエラーで落ちました。

Error [InvariantError]: Invariant: Expected cached value for cache key "..." to be a "FETCH" kind, got undefined instead. This is a bug in Next.js.

ログを確認すると、Redis からはデータが引けている(Cache Hit)し、保存したデータには kind: 'FETCH' が含まれています。なのに Next.js は「undefined だ」と言い張ります。

原因調査:Next.js は何をチェックしているのか?
エラーメッセージを手がかりに、Next.js 本体(vercel/next.js)のソースコードを調査しました。

1. データの「ガワ(Wrapper)」が足りなかった

エラーを投げている箇所を見ると、衝撃の事実が判明しました。
src/server/lib/incremental-cache/index.ts
(※ バージョンによって行数は前後しますが、ロジックは同様です)


// Next.js 内部のロジック
const cacheData = await this.cacheHandler?.get(cacheKey, ctx)

if (ctx.kind === IncrementalCacheKind.FETCH) {
  // ... (中略) ...

  // ⚠️ ここ!! .value.kind を見ている
  if (cacheData.value?.kind !== CachedRouteKind.FETCH) {
    throw new InvariantError(
      `Expected cached value for cache key ${JSON.stringify(cacheKey)} to be a "FETCH" kind, got ${JSON.stringify(cacheData.value?.kind)} instead.`
    )
  }
}

私は、Redis から get で取得したデータから value を取り出して(アンラップして)返していました。


// ❌ 間違った実装
const cachedWrapper = await repository.get(key);
return cachedWrapper.value; // 中身だけ返す

しかし Next.js は、メタデータ(lastModified)とデータ(value)がセットになったラッパーオブジェクト を期待していたのです。私が中身だけを返した結果、Next.js は (中身).value.kind を参照しようとし、当然 .value など存在しないため undefined となり、エラーが発生していました。

2. JSONシリアライズで Buffer が壊れていた

もう一つの問題は Buffer です。Next.js の Data Cache (kind: FETCH) の body は、内部的に Buffer 型で扱われることがあります。

しかし、標準の JSON.stringify は Buffer を以下のように変換してしまいます。

{ "type": "Buffer", "data": [104, 101, 108, ...] }

これでは復元時に元のバイナリデータとして認識されません。Next.js は body が文字列(または適切なBuffer)であることを期待しています。

解決策:正しい Custom Cache Handler の実装

解決策は以下の2点です。

  • 構造を維持する: get メソッドでは { lastModified, value: { ... } } の構造を崩さずに返す。
  • Base64変換: 保存時に Buffer を Base64 文字列に変換し、標準的な JSON として扱えるようにする。

実装コード

Valkey (Redis) をリポジトリとして使う場合の完成形を、ざっと記述します。

1. Repository層 (標準的なJSONを使用)

ここには特別なロジックを持たせず、単純な JSON の保存・取得に徹します。


import Redis from "ioredis";

export class ValkeyCacheRepository {
  // ... (Redis接続初期化などは省略) ...

  public async get(key: string) {
    const data = await this.client.get(key);
    return data ? JSON.parse(data) : null;
  }

  public async set(key: string, data: any, ttl?: number) {
    const serialized = JSON.stringify(data);
    if (ttl) {
      await this.client.setex(key, ttl, serialized);
    } else {
      await this.client.set(key, serialized);
    }
  }
}

2. Handler層 (構造の維持と型変換)

ここで Next.js と Redis の間の翻訳を行います。


import { ValkeyCacheRepository } from "./repository";

export default class ValkeyHandler {
  async get(cacheKey: string) {
    const repository = new ValkeyCacheRepository();
    // 1. ラッパーごと取得 ({ lastModified, value: ... })
    const cachedWrapper = await repository.get(cacheKey);

    if (!cachedWrapper) return undefined;

    const storedValue = cachedWrapper.value;

    // 2. ROUTE (HTML等) の復元: Base64 -> Stream
    if (storedValue.kind === "ROUTE" && storedValue.body) {
      const bodyBuffer = Buffer.from(storedValue.body, 'base64');
      return {
        lastModified: cachedWrapper.lastModified, // ✅ ラッパー構造を維持
        value: {
          ...storedValue,
          body: new ReadableStream({
            start(controller) {
              controller.enqueue(bodyBuffer);
              controller.close();
            },
          }),
        }
      };
    }

    // 3. FETCH (APIデータ) の復元
    // DeepWikiやOSS調査の結果、bodyはBase64文字列のままでNext.jsが解釈可能
    return {
      lastModified: cachedWrapper.lastModified, // ✅ ラッパー構造を維持
      value: storedValue 
    };
  }

  async set(cacheKey: string, pendingEntry: Promise<any>) {
    const repository = new ValkeyCacheRepository();
    const entry = await pendingEntry;
    if (!entry) return;

    let valueToSave = entry;

    // 4. 保存時の変換: Buffer/Stream -> Base64
    if (entry.kind === "ROUTE" && entry.body) {
       // Streamを読み切ってBase64化 (省略: getReader()などを使用)
       const buffer = await streamToBuffer(entry.body); 
       valueToSave = { ...entry, body: buffer.toString('base64') };
    } 
    else if (entry.kind === "FETCH" && entry.data?.body) {
      // BufferならBase64文字列に変換してJSON化できるようにする
      const body = entry.data.body;
      valueToSave = {
        ...entry,
        data: {
          ...entry.data,
          body: Buffer.isBuffer(body) ? body.toString('base64') : body
        }
      };
    }

    // 5. ラッパー構造を作って保存
    await repository.set(
      cacheKey,
      {
        lastModified: Date.now(),
        value: valueToSave, // ここに中身を入れる
      },
      entry.revalidate
    );
  }
}

まとめ

Next.js の Custom Cache Handler を実装する際は、以下の点に注意が必要です。

  • アンラップしない: Next.js は { lastModified, value: { kind: ... } } という入れ子構造を期待しています。良かれと思って .value を剥がすと InvariantError になります。
  • Buffer対策: FETCH キャッシュの body は Buffer の場合があります。Redis 等に入れる際は Base64 文字列に変換しないと、復元時に壊れます。
    • ソースコードを見る: エラーメッセージの got undefined が「値そのものが undefined」なのか「プロパティアクセスした結果が undefined」なのか、OSSのコードを追うことで特定できました。

セルフホスト勢の助けになれば幸いです。

Discussion