From 02b19d34509f50e54345ba7fc66396dd076949da Mon Sep 17 00:00:00 2001 From: Jason Roy Date: Mon, 23 Feb 2026 08:52:36 -0800 Subject: [PATCH 1/3] fix: serialize Map and Buffer in Redis ISR handler (fixes #15) Next.js 16 APP_PAGE entries store segmentData as a Map and rscData as a Buffer. Plain JSON.stringify converts Maps to {} and loses Buffer identity, causing "segmentData.get is not a function" at runtime. Add custom JSON replacer/reviver to preserve these types through the Redis serialization round-trip. Also adds APP_PAGE, APP_ROUTE, PAGES, and REDIRECT to the CacheValue type union to match the full Next.js 16 cache entry vocabulary. Co-Authored-By: Claude Opus 4.6 --- .../cache-handler/src/handlers/redis.test.ts | 300 ++++++++++++++++++ packages/cache-handler/src/handlers/redis.ts | 56 +++- packages/cache-handler/src/types.ts | 26 ++ 3 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 packages/cache-handler/src/handlers/redis.test.ts diff --git a/packages/cache-handler/src/handlers/redis.test.ts b/packages/cache-handler/src/handlers/redis.test.ts new file mode 100644 index 0000000..5b37f89 --- /dev/null +++ b/packages/cache-handler/src/handlers/redis.test.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { CacheValue } from "../types.js"; + +/** + * In-memory fake Redis for testing the ISR RedisCacheHandler. + * Mimics the ioredis API surface used by the handler. + */ +class FakeRedis { + private readonly store = new Map(); + private readonly sets = new Map>(); + readonly listeners = new Map void>>(); + + async get(key: string): Promise { + return this.store.get(key)?.value ?? null; + } + + async set(key: string, value: string): Promise { + this.store.set(key, { value }); + return "OK"; + } + + async setex(key: string, ttl: number, value: string): Promise { + this.store.set(key, { value, ttl }); + return "OK"; + } + + async del(...keys: string[]): Promise { + let count = 0; + for (const key of keys) { + if (this.store.delete(key)) count++; + } + return count; + } + + async sadd(key: string, ...members: string[]): Promise { + let set = this.sets.get(key); + if (!set) { + set = new Set(); + this.sets.set(key, set); + } + let added = 0; + for (const member of members) { + if (!set.has(member)) { + set.add(member); + added++; + } + } + return added; + } + + async smembers(key: string): Promise { + return Array.from(this.sets.get(key) ?? []); + } + + async expire(_key: string, _seconds: number): Promise { + return 1; + } + + pipeline() { + const ops: Array<() => Promise> = []; + const self = this; + return { + sadd(key: string, ...members: string[]) { + ops.push(() => self.sadd(key, ...members)); + return this; + }, + expire(key: string, seconds: number) { + ops.push(() => self.expire(key, seconds)); + return this; + }, + del(...keys: string[]) { + ops.push(() => self.del(...keys)); + return this; + }, + async exec() { + const results = []; + for (const op of ops) { + results.push([null, await op()]); + } + return results; + }, + }; + } + + on(event: string, handler: (...args: unknown[]) => void) { + let handlers = this.listeners.get(event); + if (!handlers) { + handlers = []; + this.listeners.set(event, handlers); + } + handlers.push(handler); + return this; + } +} + +// Mock ioredis to return our FakeRedis +let fakeRedis: FakeRedis; + +vi.mock("ioredis", () => { + function FakeRedisProxy() { + return fakeRedis; + } + FakeRedisProxy.prototype = {}; + return { + default: FakeRedisProxy, + Redis: FakeRedisProxy, + }; +}); + +// Import after mocking +const { RedisCacheHandler } = await import("./redis.js"); + +describe("RedisCacheHandler", () => { + beforeEach(() => { + fakeRedis = new FakeRedis(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("APP_PAGE serialization (fixes #15)", () => { + test("should round-trip APP_PAGE with segmentData Map and rscData Buffer", async () => { + const handler = new RedisCacheHandler(); + + const segmentData = new Map([ + ["/layout", Buffer.from("layout-rsc-payload")], + ["/page", Buffer.from("page-rsc-payload")], + ]); + + const value: CacheValue = { + kind: "APP_PAGE", + html: "Hello", + rscData: Buffer.from("full-rsc-data"), + headers: { "content-type": "text/html" }, + postponed: undefined, + status: 200, + segmentData, + }; + + await handler.set("app-page-key", value, { revalidate: false }); + const result = await handler.get("app-page-key"); + + expect(result).not.toBeNull(); + expect(result?.value).not.toBeNull(); + + const retrieved = result?.value as CacheValue & { kind: "APP_PAGE" }; + expect(retrieved.kind).toBe("APP_PAGE"); + expect(retrieved.html).toBe("Hello"); + + // segmentData should be restored as a Map + expect(retrieved.segmentData).toBeInstanceOf(Map); + expect(retrieved.segmentData?.size).toBe(2); + expect(retrieved.segmentData?.get("/layout")).toBeInstanceOf(Buffer); + expect(retrieved.segmentData?.get("/layout")?.toString()).toBe("layout-rsc-payload"); + expect(retrieved.segmentData?.get("/page")?.toString()).toBe("page-rsc-payload"); + + // rscData should be restored as a Buffer + expect(Buffer.isBuffer(retrieved.rscData)).toBe(true); + expect(retrieved.rscData?.toString()).toBe("full-rsc-data"); + }); + + test("should handle APP_PAGE with undefined segmentData", async () => { + const handler = new RedisCacheHandler(); + + const value: CacheValue = { + kind: "APP_PAGE", + html: "No segments", + rscData: undefined, + headers: undefined, + postponed: undefined, + status: 200, + segmentData: undefined, + }; + + await handler.set("no-segments", value, { revalidate: false }); + const result = await handler.get("no-segments"); + + expect(result).not.toBeNull(); + const retrieved = result?.value as CacheValue & { kind: "APP_PAGE" }; + expect(retrieved.kind).toBe("APP_PAGE"); + // undefined becomes null through JSON round-trip, both are falsy + expect(retrieved.segmentData).toBeFalsy(); + expect(retrieved.rscData).toBeFalsy(); + }); + + test("should handle APP_PAGE with empty segmentData Map", async () => { + const handler = new RedisCacheHandler(); + + const value: CacheValue = { + kind: "APP_PAGE", + html: "Empty segments", + rscData: Buffer.from(""), + headers: undefined, + postponed: undefined, + status: 200, + segmentData: new Map(), + }; + + await handler.set("empty-segments", value, { revalidate: false }); + const result = await handler.get("empty-segments"); + + expect(result).not.toBeNull(); + const retrieved = result?.value as CacheValue & { kind: "APP_PAGE" }; + expect(retrieved.segmentData).toBeInstanceOf(Map); + expect(retrieved.segmentData?.size).toBe(0); + }); + + test("should handle backward-compat with Node Buffer JSON format", async () => { + const handler = new RedisCacheHandler(); + + // Simulate an entry stored with plain JSON.stringify (before the fix) + // Buffer.toJSON() produces { type: "Buffer", data: [bytes...] } + const oldEntry = { + lastModified: Date.now(), + lifespan: null, + tags: [], + value: { + kind: "IMAGE", + etag: "abc", + buffer: { type: "Buffer", data: [104, 101, 108, 108, 111] }, + extension: "png", + }, + }; + + // Manually store the old-format entry + await fakeRedis.set("nextjs:cache:old-buffer", JSON.stringify(oldEntry)); + + const result = await handler.get("old-buffer"); + + expect(result).not.toBeNull(); + const retrieved = result?.value as CacheValue & { kind: "IMAGE" }; + expect(retrieved.kind).toBe("IMAGE"); + expect(Buffer.isBuffer(retrieved.buffer)).toBe(true); + expect(retrieved.buffer.toString()).toBe("hello"); + }); + }); + + describe("APP_ROUTE serialization", () => { + test("should round-trip APP_ROUTE with Buffer body", async () => { + const handler = new RedisCacheHandler(); + + const value: CacheValue = { + kind: "APP_ROUTE", + body: Buffer.from('{"message":"ok"}'), + status: 200, + headers: { "content-type": "application/json" }, + }; + + await handler.set("api-route", value, { revalidate: false }); + const result = await handler.get("api-route"); + + expect(result).not.toBeNull(); + const retrieved = result?.value as CacheValue & { kind: "APP_ROUTE" }; + expect(retrieved.kind).toBe("APP_ROUTE"); + expect(Buffer.isBuffer(retrieved.body)).toBe(true); + expect(retrieved.body.toString()).toBe('{"message":"ok"}'); + }); + }); + + describe("existing value types still work", () => { + test("should round-trip FETCH values", async () => { + const handler = new RedisCacheHandler(); + + const value: CacheValue = { + kind: "FETCH", + data: { + headers: { "content-type": "application/json" }, + body: '{"test": true}', + status: 200, + url: "https://example.com", + }, + revalidate: 60, + }; + + await handler.set("fetch-key", value, { revalidate: false }); + const result = await handler.get("fetch-key"); + + expect(result).not.toBeNull(); + expect(result?.value).toEqual(value); + }); + + test("should round-trip PAGE values", async () => { + const handler = new RedisCacheHandler(); + + const value: CacheValue = { + kind: "PAGE", + html: "test", + pageData: { props: { test: true } }, + status: 200, + }; + + await handler.set("page-key", value, { revalidate: false }); + const result = await handler.get("page-key"); + + expect(result).not.toBeNull(); + expect(result?.value).toEqual(value); + }); + }); +}); diff --git a/packages/cache-handler/src/handlers/redis.ts b/packages/cache-handler/src/handlers/redis.ts index 156d897..5ed48fa 100644 --- a/packages/cache-handler/src/handlers/redis.ts +++ b/packages/cache-handler/src/handlers/redis.ts @@ -10,6 +10,56 @@ import type { CacheValue, } from "../types.js"; +/** + * Custom JSON replacer that serializes Map and Buffer instances. + * - Maps become `{ __serialized_type: "Map", entries: [...] }` + * - Buffers become `{ __serialized_type: "Buffer", data: "" }` + * + * This is needed because Next.js 16 APP_PAGE entries store `segmentData` + * as a Map and `rscData` as a Buffer, neither of which + * survive a plain JSON.stringify round-trip. + */ +function jsonReplacer(_key: string, value: unknown): unknown { + if (value instanceof Map) { + return { + __serialized_type: "Map", + entries: Array.from(value.entries()), + }; + } + if (Buffer.isBuffer(value)) { + return { + __serialized_type: "Buffer", + data: value.toString("base64"), + }; + } + return value; +} + +/** + * Custom JSON reviver that restores Map and Buffer instances. + * Also handles backward-compat with Node's native Buffer JSON + * representation `{ type: "Buffer", data: [byte, ...] }`. + */ +function jsonReviver(_key: string, value: unknown): unknown { + if (value && typeof value === "object") { + const obj = value as Record; + + if (obj.__serialized_type === "Map" && Array.isArray(obj.entries)) { + return new Map(obj.entries as [unknown, unknown][]); + } + + if (obj.__serialized_type === "Buffer" && typeof obj.data === "string") { + return Buffer.from(obj.data, "base64"); + } + + // Backward compat: Node's Buffer.toJSON() format + if (obj.type === "Buffer" && Array.isArray(obj.data)) { + return Buffer.from(obj.data as number[]); + } + } + return value; +} + export interface RedisCacheHandlerOptions extends CacheHandlerOptions { /** * Redis connection options (ioredis) @@ -132,8 +182,8 @@ export class RedisCacheHandler implements CacheHandler { return null; } - // Parse the stored entry - const entry: CacheHandlerValue = JSON.parse(data); + // Parse the stored entry (reviver restores Map/Buffer instances) + const entry: CacheHandlerValue = JSON.parse(data, jsonReviver); // Check if expired based on lifespan if (entry.lifespan && isExpired(entry.lifespan)) { @@ -186,7 +236,7 @@ export class RedisCacheHandler implements CacheHandler { value, }; - const serialized = JSON.stringify(entry); + const serialized = JSON.stringify(entry, jsonReplacer); // Determine TTL for Redis let ttl: number | undefined; diff --git a/packages/cache-handler/src/types.ts b/packages/cache-handler/src/types.ts index 20f87d0..f914ab5 100644 --- a/packages/cache-handler/src/types.ts +++ b/packages/cache-handler/src/types.ts @@ -12,6 +12,21 @@ export const IMPLICIT_TAG_PREFIX = "_N_T_"; * Cache entry value types supported by Next.js */ export type CacheValue = + | { + kind: "APP_PAGE"; + html: string; + rscData: Buffer | undefined; + headers: Record | undefined; + postponed: string | undefined; + status: number | undefined; + segmentData: Map | undefined; + } + | { + kind: "APP_ROUTE"; + body: Buffer; + status: number; + headers: Record; + } | { kind: "ROUTE"; html: string; @@ -26,6 +41,13 @@ export type CacheValue = status?: number; headers?: Record; } + | { + kind: "PAGES"; + html: string; + pageData: Record; + status?: number; + headers?: Record; + } | { kind: "FETCH"; data: { @@ -36,6 +58,10 @@ export type CacheValue = }; revalidate: number | false; } + | { + kind: "REDIRECT"; + props: Record; + } | { kind: "IMAGE"; etag: string; From 5196dd4db89634377e393944c7cf1a7f72e71620 Mon Sep 17 00:00:00 2001 From: Jason Roy Date: Mon, 23 Feb 2026 11:39:38 -0800 Subject: [PATCH 2/3] fix: add defensive validation for Map entries in jsonReviver Validate that deserialized Map entries are proper [key, value] pairs before constructing a Map, guarding against corrupted cache data. Addresses Gemini Code Assist review suggestion on PR #29. Co-Authored-By: Claude Opus 4.6 --- packages/cache-handler/src/handlers/redis.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cache-handler/src/handlers/redis.ts b/packages/cache-handler/src/handlers/redis.ts index 5ed48fa..53c2454 100644 --- a/packages/cache-handler/src/handlers/redis.ts +++ b/packages/cache-handler/src/handlers/redis.ts @@ -44,7 +44,11 @@ function jsonReviver(_key: string, value: unknown): unknown { if (value && typeof value === "object") { const obj = value as Record; - if (obj.__serialized_type === "Map" && Array.isArray(obj.entries)) { + if ( + obj.__serialized_type === "Map" && + Array.isArray(obj.entries) && + obj.entries.every((e) => Array.isArray(e) && e.length === 2) + ) { return new Map(obj.entries as [unknown, unknown][]); } From 09cc9b7ea93e13cf1f560933623aebbea2b70c0c Mon Sep 17 00:00:00 2001 From: Jason Roy Date: Mon, 23 Feb 2026 14:34:45 -0800 Subject: [PATCH 3/3] fix: address code review findings for PR #29 High priority: - Force cache miss for old APP_PAGE entries with plain object segmentData (entries stored before the Map serialization fix would crash Next.js) - Add missing upstreamEtag field to IMAGE type to match Next.js 16 - Add IMAGE round-trip test through Redis handler Medium priority: - Tighten Buffer backward-compat check with number[] validation to avoid false-positives on user data with { type: "Buffer", data: [...] } - Fix memory handler size calculation to use jsonReplacer (Maps were estimated as "{}" = 2 bytes regardless of actual content) - Remove stale ROUTE and PAGE kinds from CacheValue (Next.js 16 uses APP_PAGE, APP_ROUTE, PAGES, FETCH, REDIRECT, IMAGE) - Add binary data Buffer test with non-UTF8 bytes (0x00, 0xff, etc.) Also: - Extract jsonReplacer/jsonReviver to shared helpers/serialization.ts - Export serialization helpers from public API for custom handler reuse - Add REDIRECT round-trip test - Update all test files to use Next.js 16 cache value kinds Co-Authored-By: Claude Opus 4.6 --- .../cache-handler/src/handlers/memory.test.ts | 6 +- packages/cache-handler/src/handlers/memory.ts | 5 +- .../cache-handler/src/handlers/redis.test.ts | 111 +++++++++++++++++- packages/cache-handler/src/handlers/redis.ts | 70 +++-------- .../src/handlers/return-type.test.ts | 19 +-- .../src/helpers/serialization.ts | 63 ++++++++++ packages/cache-handler/src/index.ts | 1 + packages/cache-handler/src/types.ts | 17 +-- 8 files changed, 207 insertions(+), 85 deletions(-) create mode 100644 packages/cache-handler/src/helpers/serialization.ts diff --git a/packages/cache-handler/src/handlers/memory.test.ts b/packages/cache-handler/src/handlers/memory.test.ts index c5b1055..2c0dcb0 100644 --- a/packages/cache-handler/src/handlers/memory.test.ts +++ b/packages/cache-handler/src/handlers/memory.test.ts @@ -154,7 +154,7 @@ describe("MemoryCacheHandler", () => { describe("TTL and expiration", () => { test("should expire entries based on revalidate time", async () => { const value: CacheValue = { - kind: "PAGE", + kind: "PAGES", html: "test", pageData: { test: true }, }; @@ -197,7 +197,7 @@ describe("MemoryCacheHandler", () => { const handlerWithTTL = createMemoryCacheHandler({ defaultTTL: 1 }); const value: CacheValue = { - kind: "PAGE", + kind: "PAGES", html: "test", pageData: { test: true }, }; @@ -244,7 +244,7 @@ describe("MemoryCacheHandler", () => { test("should handle implicit tags via meta parameter", async () => { const value: CacheValue = { - kind: "PAGE", + kind: "PAGES", html: "test", pageData: { test: true }, }; diff --git a/packages/cache-handler/src/handlers/memory.ts b/packages/cache-handler/src/handlers/memory.ts index ade3f95..c19a657 100644 --- a/packages/cache-handler/src/handlers/memory.ts +++ b/packages/cache-handler/src/handlers/memory.ts @@ -1,5 +1,6 @@ import { isImplicitTag } from "../helpers/is-implicit-tag.js"; import { calculateLifespan, isExpired } from "../helpers/lifespan.js"; +import { jsonReplacer } from "../helpers/serialization.js"; import type { CacheHandler, CacheHandlerContext, @@ -105,8 +106,8 @@ export class MemoryCacheHandler implements CacheHandler { } async set(key: string, value: CacheValue, context?: CacheHandlerContext): Promise { - // Calculate size of the entry - const size = JSON.stringify(value).length; + // Calculate size of the entry (use replacer to account for Map/Buffer) + const size = JSON.stringify(value, jsonReplacer).length; // Check if entry exceeds max size if (size > this.maxItemSizeBytes) { diff --git a/packages/cache-handler/src/handlers/redis.test.ts b/packages/cache-handler/src/handlers/redis.test.ts index 5b37f89..722b73e 100644 --- a/packages/cache-handler/src/handlers/redis.test.ts +++ b/packages/cache-handler/src/handlers/redis.test.ts @@ -218,6 +218,7 @@ describe("RedisCacheHandler", () => { value: { kind: "IMAGE", etag: "abc", + upstreamEtag: "upstream-abc", buffer: { type: "Buffer", data: [104, 101, 108, 108, 111] }, extension: "png", }, @@ -280,11 +281,11 @@ describe("RedisCacheHandler", () => { expect(result?.value).toEqual(value); }); - test("should round-trip PAGE values", async () => { + test("should round-trip PAGES values", async () => { const handler = new RedisCacheHandler(); const value: CacheValue = { - kind: "PAGE", + kind: "PAGES", html: "test", pageData: { props: { test: true } }, status: 200, @@ -296,5 +297,111 @@ describe("RedisCacheHandler", () => { expect(result).not.toBeNull(); expect(result?.value).toEqual(value); }); + + test("should round-trip IMAGE values with Buffer", async () => { + const handler = new RedisCacheHandler(); + + const value: CacheValue = { + kind: "IMAGE", + etag: "abc123", + upstreamEtag: "upstream-abc123", + buffer: Buffer.from("fake-image-data"), + extension: "png", + }; + + await handler.set("image-key", value, { revalidate: false }); + const result = await handler.get("image-key"); + + expect(result).not.toBeNull(); + const retrieved = result?.value as CacheValue & { kind: "IMAGE" }; + expect(retrieved.kind).toBe("IMAGE"); + expect(retrieved.etag).toBe("abc123"); + expect(retrieved.upstreamEtag).toBe("upstream-abc123"); + expect(Buffer.isBuffer(retrieved.buffer)).toBe(true); + expect(retrieved.buffer.toString()).toBe("fake-image-data"); + }); + + test("should round-trip REDIRECT values", async () => { + const handler = new RedisCacheHandler(); + + const value: CacheValue = { + kind: "REDIRECT", + props: { destination: "/new-page", permanent: true }, + }; + + await handler.set("redirect-key", value, { revalidate: false }); + const result = await handler.get("redirect-key"); + + expect(result).not.toBeNull(); + expect(result?.value).toEqual(value); + }); + }); + + describe("binary data round-trip", () => { + test("should preserve non-UTF8 binary data in Buffers", async () => { + const handler = new RedisCacheHandler(); + const binaryData = Buffer.from([0x00, 0xff, 0x80, 0xde, 0xad, 0xbe, 0xef, 0x01]); + + const value: CacheValue = { + kind: "APP_PAGE", + html: "binary test", + rscData: binaryData, + headers: undefined, + postponed: undefined, + status: 200, + segmentData: new Map([ + ["/binary-segment", Buffer.from([0x00, 0x01, 0xfe, 0xff])], + ]), + }; + + await handler.set("binary-key", value, { revalidate: false }); + const result = await handler.get("binary-key"); + + expect(result).not.toBeNull(); + const retrieved = result?.value as CacheValue & { kind: "APP_PAGE" }; + + // rscData should be byte-for-byte identical + expect(Buffer.isBuffer(retrieved.rscData)).toBe(true); + expect(retrieved.rscData).toEqual(binaryData); + + // segmentData buffer should be byte-for-byte identical + const segBuf = retrieved.segmentData?.get("/binary-segment"); + expect(Buffer.isBuffer(segBuf)).toBe(true); + expect(segBuf).toEqual(Buffer.from([0x00, 0x01, 0xfe, 0xff])); + }); + }); + + describe("backward compatibility", () => { + test("should force cache miss for old APP_PAGE entries with plain object segmentData", async () => { + const handler = new RedisCacheHandler(); + + // Simulate an old entry stored before the Map serialization fix. + // JSON.stringify(new Map([...])) produces "{}", so segmentData is + // a plain empty object after deserialization. + const oldEntry = { + lastModified: Date.now(), + lifespan: null, + tags: [], + value: { + kind: "APP_PAGE", + html: "old format", + rscData: null, + headers: null, + postponed: null, + status: 200, + segmentData: {}, + }, + }; + + await fakeRedis.set("nextjs:cache:old-app-page", JSON.stringify(oldEntry)); + const result = await handler.get("old-app-page"); + + // Should return null (cache miss) instead of corrupt data + expect(result).toBeNull(); + + // The old entry should have been deleted from Redis + const rawData = await fakeRedis.get("nextjs:cache:old-app-page"); + expect(rawData).toBeNull(); + }); }); }); diff --git a/packages/cache-handler/src/handlers/redis.ts b/packages/cache-handler/src/handlers/redis.ts index 53c2454..cb80fc2 100644 --- a/packages/cache-handler/src/handlers/redis.ts +++ b/packages/cache-handler/src/handlers/redis.ts @@ -1,5 +1,6 @@ import Redis, { type RedisOptions } from "ioredis"; import { calculateLifespan, isExpired } from "../helpers/lifespan.js"; +import { jsonReplacer, jsonReviver } from "../helpers/serialization.js"; import type { CacheHandler, CacheHandlerContext, @@ -10,60 +11,6 @@ import type { CacheValue, } from "../types.js"; -/** - * Custom JSON replacer that serializes Map and Buffer instances. - * - Maps become `{ __serialized_type: "Map", entries: [...] }` - * - Buffers become `{ __serialized_type: "Buffer", data: "" }` - * - * This is needed because Next.js 16 APP_PAGE entries store `segmentData` - * as a Map and `rscData` as a Buffer, neither of which - * survive a plain JSON.stringify round-trip. - */ -function jsonReplacer(_key: string, value: unknown): unknown { - if (value instanceof Map) { - return { - __serialized_type: "Map", - entries: Array.from(value.entries()), - }; - } - if (Buffer.isBuffer(value)) { - return { - __serialized_type: "Buffer", - data: value.toString("base64"), - }; - } - return value; -} - -/** - * Custom JSON reviver that restores Map and Buffer instances. - * Also handles backward-compat with Node's native Buffer JSON - * representation `{ type: "Buffer", data: [byte, ...] }`. - */ -function jsonReviver(_key: string, value: unknown): unknown { - if (value && typeof value === "object") { - const obj = value as Record; - - if ( - obj.__serialized_type === "Map" && - Array.isArray(obj.entries) && - obj.entries.every((e) => Array.isArray(e) && e.length === 2) - ) { - return new Map(obj.entries as [unknown, unknown][]); - } - - if (obj.__serialized_type === "Buffer" && typeof obj.data === "string") { - return Buffer.from(obj.data, "base64"); - } - - // Backward compat: Node's Buffer.toJSON() format - if (obj.type === "Buffer" && Array.isArray(obj.data)) { - return Buffer.from(obj.data as number[]); - } - } - return value; -} - export interface RedisCacheHandlerOptions extends CacheHandlerOptions { /** * Redis connection options (ioredis) @@ -211,6 +158,21 @@ export class RedisCacheHandler implements CacheHandler { } } + // Invalidate old APP_PAGE entries where segmentData was stored as a + // plain object (pre-fix serialization). Next.js expects a Map and would + // crash on .get() if we returned a plain object. + if ( + entry.value && + (entry.value as Record).kind === "APP_PAGE" && + (entry.value as Record).segmentData !== undefined && + (entry.value as Record).segmentData !== null && + !((entry.value as Record).segmentData instanceof Map) + ) { + this.log("GET", cacheKey, "STALE (old APP_PAGE format without Map serialization)"); + await this.delete(key); + return null; + } + this.log("GET", cacheKey, "HIT"); // Return the cache handler result with value and metadata diff --git a/packages/cache-handler/src/handlers/return-type.test.ts b/packages/cache-handler/src/handlers/return-type.test.ts index 3fd8ef6..40d82f1 100644 --- a/packages/cache-handler/src/handlers/return-type.test.ts +++ b/packages/cache-handler/src/handlers/return-type.test.ts @@ -197,9 +197,9 @@ describe("CacheHandler return type (Issue #12)", () => { } }); - test("should return correct structure for PAGE kind", async () => { + test("should return correct structure for PAGES kind", async () => { const pageValue: CacheValue = { - kind: "PAGE", + kind: "PAGES", html: "Test Page", pageData: { title: "Test" }, }; @@ -208,32 +208,33 @@ describe("CacheHandler return type (Issue #12)", () => { const result = await handler.get("page-key"); expect(result).not.toBeNull(); - expect(result?.value?.kind).toBe("PAGE"); - if (result?.value?.kind === "PAGE") { + expect(result?.value?.kind).toBe("PAGES"); + if (result?.value?.kind === "PAGES") { expect(result.value.html).toContain("Test Page"); expect(result.value.pageData).toEqual({ title: "Test" }); } }); - test("should return correct structure for ROUTE kind", async () => { + test("should return correct structure for APP_ROUTE kind", async () => { const routeValue: CacheValue = { - kind: "ROUTE", - html: "
Route content
", - pageData: { route: "/test" }, + kind: "APP_ROUTE", + body: Buffer.from("Route content"), status: 200, + headers: { "content-type": "text/html" }, }; await handler.set("route-key", routeValue); const result = await handler.get("route-key"); expect(result).not.toBeNull(); - expect(result?.value?.kind).toBe("ROUTE"); + expect(result?.value?.kind).toBe("APP_ROUTE"); }); test("should return correct structure for IMAGE kind", async () => { const imageValue: CacheValue = { kind: "IMAGE", etag: "abc123", + upstreamEtag: "upstream-abc123", buffer: Buffer.from("image data"), extension: "png", }; diff --git a/packages/cache-handler/src/helpers/serialization.ts b/packages/cache-handler/src/helpers/serialization.ts new file mode 100644 index 0000000..c5b804e --- /dev/null +++ b/packages/cache-handler/src/helpers/serialization.ts @@ -0,0 +1,63 @@ +/** + * JSON serialization helpers for cache values containing Map and Buffer instances. + * + * Next.js 16 APP_PAGE entries store `segmentData` as a Map + * and `rscData` as a Buffer. Plain JSON.stringify converts Maps to `{}` and + * loses Buffer identity, so custom replacer/reviver functions are needed. + */ + +/** + * Custom JSON replacer that serializes Map and Buffer instances. + * - Maps become `{ __serialized_type: "Map", entries: [...] }` + * - Buffers become `{ __serialized_type: "Buffer", data: "" }` + */ +export function jsonReplacer(_key: string, value: unknown): unknown { + if (value instanceof Map) { + return { + __serialized_type: "Map", + entries: Array.from(value.entries()), + }; + } + if (Buffer.isBuffer(value)) { + return { + __serialized_type: "Buffer", + data: value.toString("base64"), + }; + } + return value; +} + +/** + * Custom JSON reviver that restores Map and Buffer instances. + * Also handles backward-compat with Node's native Buffer JSON + * representation `{ type: "Buffer", data: [byte, ...] }`. + */ +export function jsonReviver(_key: string, value: unknown): unknown { + if (value && typeof value === "object") { + const obj = value as Record; + + if ( + obj.__serialized_type === "Map" && + Array.isArray(obj.entries) && + obj.entries.every((e) => Array.isArray(e) && e.length === 2) + ) { + return new Map(obj.entries as [unknown, unknown][]); + } + + if (obj.__serialized_type === "Buffer" && typeof obj.data === "string") { + return Buffer.from(obj.data, "base64"); + } + + // Backward compat: Node's Buffer.toJSON() format. + // Guard with number[] check to avoid false-positives on user data + // that happens to have { type: "Buffer", data: [...] } shape. + if ( + obj.type === "Buffer" && + Array.isArray(obj.data) && + obj.data.every((n) => typeof n === "number") + ) { + return Buffer.from(obj.data as number[]); + } + } + return value; +} diff --git a/packages/cache-handler/src/index.ts b/packages/cache-handler/src/index.ts index 7396e4c..bb876be 100644 --- a/packages/cache-handler/src/index.ts +++ b/packages/cache-handler/src/index.ts @@ -25,6 +25,7 @@ export { export { bufferToString, stringToBuffer } from "./helpers/buffer.js"; // Helpers export { isImplicitTag } from "./helpers/is-implicit-tag.js"; +export { jsonReplacer, jsonReviver } from "./helpers/serialization.js"; export { calculateLifespan, isExpired } from "./helpers/lifespan.js"; export { type CacheHandlerConfig, diff --git a/packages/cache-handler/src/types.ts b/packages/cache-handler/src/types.ts index f914ab5..69cffb6 100644 --- a/packages/cache-handler/src/types.ts +++ b/packages/cache-handler/src/types.ts @@ -27,20 +27,6 @@ export type CacheValue = status: number; headers: Record; } - | { - kind: "ROUTE"; - html: string; - pageData: Record; - status?: number; - headers?: Record; - } - | { - kind: "PAGE"; - html: string; - pageData: Record; - status?: number; - headers?: Record; - } | { kind: "PAGES"; html: string; @@ -65,6 +51,7 @@ export type CacheValue = | { kind: "IMAGE"; etag: string; + upstreamEtag: string; buffer: Buffer; extension: string; isMiss?: boolean; @@ -169,7 +156,7 @@ export interface CacheHandlerGetMeta { */ export interface CacheHandlerGetResult { /** - * The actual cache value (ROUTE, PAGE, FETCH, or IMAGE) + * The actual cache value (APP_PAGE, APP_ROUTE, PAGES, FETCH, REDIRECT, or IMAGE) */ value: CacheValue | null;