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 diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index 7b616d5ca..51571b77d 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -5,6 +5,7 @@ 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"; @@ -268,6 +269,31 @@ 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; } 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", + }), + }), + ); + }); });