From e8abba34c96459069dfd9528d9db27b00d37c7e9 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 11 Aug 2025 20:43:32 +0200 Subject: [PATCH 1/4] feat: Add binary content type handling in cacheInterceptor --- .../src/core/routing/cacheInterceptor.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index 7b616d5ca..e2416a6dc 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -9,6 +9,7 @@ import { getTagsFromValue, hasBeenRevalidated } from "utils/cache"; import { debug } from "../../adapters/logger"; import { localizePath } from "./i18n"; import { generateMessageGroupId } from "./queue"; +import { isBinaryContentType } from "utils/binary"; const CACHE_ONE_YEAR = 60 * 60 * 24 * 365; const CACHE_ONE_MONTH = 60 * 60 * 24 * 30; @@ -268,6 +269,29 @@ export async function cacheInterceptor( isBase64Encoded: false, }; } + case "route": { + const cacheControl = await computeCacheControl( + localizedPath, + cachedData.value.body, + host, + cachedData.value.revalidate, + cachedData.lastModified, + ); + + const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"])); + + return { + type: "core", + statusCode: cachedData.value.meta?.status ?? 200, + body: toReadableStream(cachedData.value.body, isBinary), + headers: { + ...cacheControl, + ...cachedData.value.meta?.headers, + vary: VARY_HEADER, + }, + isBase64Encoded: isBinary, + }; + } default: return event; } From f15526f738788d42dcf8440ac8c95a340ab1f0ab Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 11 Aug 2025 20:54:25 +0200 Subject: [PATCH 2/4] add test for route type --- .../core/routing/cacheInterceptor.test.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index e75288e0a..952ff3df4 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -278,4 +278,159 @@ describe("cacheInterceptor", () => { expect(result).toEqual(event); }); + + it("should retrieve route content from cache with text content", async () => { + const event = createEvent({ + url: "/albums", + }); + const routeBody = JSON.stringify({ message: "Hello from API" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: routeBody, + meta: { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + revalidate: 300, + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + + const result = await cacheInterceptor(event); + + const body = await fromReadableStream(result.body); + expect(body).toEqual(routeBody); + expect(result).toEqual( + expect.objectContaining({ + type: "core", + statusCode: 200, + isBase64Encoded: false, + headers: expect.objectContaining({ + "cache-control": "s-maxage=300, stale-while-revalidate=2592000", + "content-type": "application/json", + etag: expect.any(String), + "x-opennext-cache": "HIT", + vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url", + }), + }), + ); + }); + + it("should retrieve route content from cache with binary content", async () => { + const event = createEvent({ + url: "/albums", + }); + const routeBody = "randomBinaryData"; + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: routeBody, + meta: { + status: 200, + headers: { + "content-type": "image/png", + }, + }, + revalidate: false, + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + + const result = await cacheInterceptor(event); + + const body = await fromReadableStream(result.body, true); + expect(body).toEqual(routeBody); + expect(result).toEqual( + expect.objectContaining({ + type: "core", + statusCode: 200, + isBase64Encoded: true, + headers: expect.objectContaining({ + "cache-control": "s-maxage=31536000, stale-while-revalidate=2592000", + "content-type": "image/png", + etag: expect.any(String), + "x-opennext-cache": "HIT", + vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url", + }), + }), + ); + }); + + it("should retrieve route content from stale cache", async () => { + const event = createEvent({ + url: "/albums", + }); + const routeBody = "API response"; + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: routeBody, + meta: { + status: 201, + headers: { + "content-type": "text/plain", + "custom-header": "custom-value", + }, + }, + revalidate: 60, + }, + lastModified: new Date("2024-01-01T23:58:00Z").getTime(), + }); + + const result = await cacheInterceptor(event); + + const body = await fromReadableStream(result.body); + expect(body).toEqual(routeBody); + expect(result).toEqual( + expect.objectContaining({ + type: "core", + statusCode: 201, + isBase64Encoded: false, + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "content-type": "text/plain", + "custom-header": "custom-value", + etag: expect.any(String), + "x-opennext-cache": "STALE", + vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url", + }), + }), + ); + }); + + it("should retrieve route content with default status code when meta is missing", async () => { + const event = createEvent({ + url: "/albums", + }); + const routeBody = "Simple response"; + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: routeBody, + revalidate: false, + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + + const result = await cacheInterceptor(event); + + const body = await fromReadableStream(result.body); + expect(body).toEqual(routeBody); + expect(result).toEqual( + expect.objectContaining({ + type: "core", + statusCode: 200, + isBase64Encoded: false, + headers: expect.objectContaining({ + "cache-control": "s-maxage=31536000, stale-while-revalidate=2592000", + etag: expect.any(String), + "x-opennext-cache": "HIT", + vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url", + }), + }), + ); + }); }); From eecbaa447d64ca5711e1dc74fc253eebc638f578 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 11 Aug 2025 20:55:02 +0200 Subject: [PATCH 3/4] changeset --- .changeset/perfect-jeans-divide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perfect-jeans-divide.md diff --git a/.changeset/perfect-jeans-divide.md b/.changeset/perfect-jeans-divide.md new file mode 100644 index 000000000..2ad6ed91f --- /dev/null +++ b/.changeset/perfect-jeans-divide.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +add support for route type in cache interceptor From 49be16bc097a34439ead10ed81577e852d0bd952 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 11 Aug 2025 20:55:22 +0200 Subject: [PATCH 4/4] linting --- packages/open-next/src/core/routing/cacheInterceptor.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index e2416a6dc..51571b77d 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -5,11 +5,11 @@ import type { InternalEvent, InternalResult } from "types/open-next"; import type { CacheValue } from "types/overrides"; import { emptyReadableStream, toReadableStream } from "utils/stream"; +import { isBinaryContentType } from "utils/binary"; import { getTagsFromValue, hasBeenRevalidated } from "utils/cache"; import { debug } from "../../adapters/logger"; import { localizePath } from "./i18n"; import { generateMessageGroupId } from "./queue"; -import { isBinaryContentType } from "utils/binary"; const CACHE_ONE_YEAR = 60 * 60 * 24 * 365; const CACHE_ONE_MONTH = 60 * 60 * 24 * 30; @@ -278,7 +278,9 @@ export async function cacheInterceptor( cachedData.lastModified, ); - const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"])); + const isBinary = isBinaryContentType( + String(cachedData.value.meta?.headers?.["content-type"]), + ); return { type: "core",