diff --git a/.changeset/slow-walls-allow.md b/.changeset/slow-walls-allow.md new file mode 100644 index 000000000..833669777 --- /dev/null +++ b/.changeset/slow-walls-allow.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/aws": minor +--- + +Add an option to keep the data cache persistent between deployments. + +BREAKING CHANGE: Incremental cache keys are now an object of type `CacheKey` instead of a string. The new type includes properties like `baseKey`, `buildId`, and `cacheType`. Build_id is automatically provided according to the cache type and the `dangerous.persistentDataCache` option. Up to the Incremental Cache implementation to use it as they see fit. \ No newline at end of file diff --git a/packages/open-next/src/adapters/cache.ts b/packages/open-next/src/adapters/cache.ts index 6f0945b93..13436bfaf 100644 --- a/packages/open-next/src/adapters/cache.ts +++ b/packages/open-next/src/adapters/cache.ts @@ -3,7 +3,13 @@ import type { IncrementalCacheContext, IncrementalCacheValue, } from "types/cache"; -import { getTagsFromValue, hasBeenRevalidated, writeTags } from "utils/cache"; +import type { CacheKey } from "types/overrides"; +import { + createCacheKey, + getTagsFromValue, + hasBeenRevalidated, + writeTags, +} from "utils/cache"; import { isBinaryContentType } from "../utils/binary"; import { debug, error, warn } from "./logger"; @@ -31,7 +37,7 @@ function isFetchCache( // We need to use globalThis client here as this class can be defined at load time in next 12 but client is not available at load time export default class Cache { public async get( - key: string, + baseKey: string, // fetchCache is for next 13.5 and above, kindHint is for next 14 and above and boolean is for earlier versions options?: | boolean @@ -50,21 +56,22 @@ export default class Cache { const softTags = typeof options === "object" ? options.softTags : []; const tags = typeof options === "object" ? options.tags : []; return isFetchCache(options) - ? this.getFetchCache(key, softTags, tags) - : this.getIncrementalCache(key); + ? this.getFetchCache(baseKey, softTags, tags) + : this.getIncrementalCache(baseKey); } - async getFetchCache(key: string, softTags?: string[], tags?: string[]) { - debug("get fetch cache", { key, softTags, tags }); + async getFetchCache(baseKey: string, softTags?: string[], tags?: string[]) { + debug("get fetch cache", { baseKey, softTags, tags }); try { - const cachedEntry = await globalThis.incrementalCache.get(key, "fetch"); + const key = createCacheKey({ key: baseKey, type: "fetch" }); + const cachedEntry = await globalThis.incrementalCache.get(key); if (cachedEntry?.value === undefined) return null; const _tags = [...(tags ?? []), ...(softTags ?? [])]; const _lastModified = cachedEntry.lastModified ?? Date.now(); const _hasBeenRevalidated = await hasBeenRevalidated( - key, + baseKey, _tags, cachedEntry, ); @@ -105,9 +112,15 @@ export default class Cache { } } - async getIncrementalCache(key: string): Promise { + async getIncrementalCache( + baseKey: string, + ): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); + const key = createCacheKey({ + key: baseKey, + type: "cache", + }); + const cachedEntry = await globalThis.incrementalCache.get(key); if (!cachedEntry?.value) { return null; @@ -119,7 +132,7 @@ export default class Cache { const tags = getTagsFromValue(cacheData); const _lastModified = cachedEntry.lastModified ?? Date.now(); const _hasBeenRevalidated = await hasBeenRevalidated( - key, + baseKey, tags, cachedEntry, ); @@ -191,13 +204,18 @@ export default class Cache { } async set( - key: string, + baseKey: string, data?: IncrementalCacheValue, ctx?: IncrementalCacheContext, ): Promise { if (globalThis.openNextConfig.dangerous?.disableIncrementalCache) { return; } + const key = createCacheKey({ + key: baseKey, + type: data?.kind === "FETCH" ? "fetch" : "cache", + }); + debug("Setting cache", { key, data, ctx }); // This one might not even be necessary anymore // Better be safe than sorry const detachedPromise = globalThis.__openNextAls @@ -205,30 +223,27 @@ export default class Cache { ?.pendingPromiseRunner.withResolvers(); try { if (data === null || data === undefined) { - await globalThis.incrementalCache.delete(key); + // only case where we delete the cache is for ISR/SSG cache + await globalThis.incrementalCache.delete(key as CacheKey<"cache">); } else { const revalidate = this.extractRevalidateForSet(ctx); switch (data.kind) { case "ROUTE": case "APP_ROUTE": { const { body, status, headers } = data; - await globalThis.incrementalCache.set( - key, - { - type: "route", - body: body.toString( - isBinaryContentType(String(headers["content-type"])) - ? "base64" - : "utf8", - ), - meta: { - status, - headers, - }, - revalidate, + await globalThis.incrementalCache.set(key, { + type: "route", + body: body.toString( + isBinaryContentType(String(headers["content-type"])) + ? "base64" + : "utf8", + ), + meta: { + status, + headers, }, - "cache", - ); + revalidate, + }); break; } case "PAGE": @@ -236,65 +251,49 @@ export default class Cache { const { html, pageData, status, headers } = data; const isAppPath = typeof pageData === "string"; if (isAppPath) { - await globalThis.incrementalCache.set( - key, - { - type: "app", - html, - rsc: pageData, - meta: { - status, - headers, - }, - revalidate, - }, - "cache", - ); - } else { - await globalThis.incrementalCache.set( - key, - { - type: "page", - html, - json: pageData, - revalidate, - }, - "cache", - ); - } - break; - } - case "APP_PAGE": { - const { html, rscData, headers, status } = data; - await globalThis.incrementalCache.set( - key, - { + await globalThis.incrementalCache.set(key, { type: "app", html, - rsc: rscData.toString("utf8"), + rsc: pageData, meta: { status, headers, }, revalidate, + }); + } else { + await globalThis.incrementalCache.set(key, { + type: "page", + html, + json: pageData, + revalidate, + }); + } + break; + } + case "APP_PAGE": { + const { html, rscData, headers, status } = data; + await globalThis.incrementalCache.set(key, { + type: "app", + html, + rsc: rscData.toString("utf8"), + meta: { + status, + headers, }, - "cache", - ); + revalidate, + }); break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, "fetch"); + await globalThis.incrementalCache.set(key, data); break; case "REDIRECT": - await globalThis.incrementalCache.set( - key, - { - type: "redirect", - props: data.props, - revalidate, - }, - "cache", - ); + await globalThis.incrementalCache.set(key, { + type: "redirect", + props: data.props, + revalidate, + }); break; case "IMAGE": // Not implemented @@ -302,7 +301,7 @@ export default class Cache { } } - await this.updateTagsOnSet(key, data, ctx); + await this.updateTagsOnSet(baseKey, data, ctx); debug("Finished setting cache"); } catch (e) { error("Failed to set cache", e); diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index 0c4fecdc2..920ab0684 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -1,22 +1,48 @@ import type { ComposableCacheEntry, ComposableCacheHandler } from "types/cache"; +import type { CacheKey } from "types/overrides"; import { writeTags } from "utils/cache"; import { fromReadableStream, toReadableStream } from "utils/stream"; -import { debug } from "./logger"; +import { debug, warn } from "./logger"; const pendingWritePromiseMap = new Map>(); +/** + * Get the cache key for a composable entry. + * Composable cache keys are a special cases as they are a stringified version of a tuple composed of a representation of the BUILD_ID and the actual key. + * @param key The composable cache key + * @returns The composable cache key. + */ +function getComposableCacheKey(key: string): CacheKey<"composable"> { + try { + const shouldPrependBuildId = + globalThis.openNextConfig.dangerous?.persistentDataCache !== true; + const [_buildId, ...rest] = JSON.parse(key); + return { + cacheType: "composable", + buildId: shouldPrependBuildId ? _buildId : undefined, + baseKey: JSON.stringify(rest), + } as CacheKey<"composable">; + } catch (e) { + warn("Error while parsing composable cache key", e); + // If we fail to parse the key, we just return it as is + // This is not ideal, but we don't want to crash the application + return { + cacheType: "composable", + buildId: process.env.NEXT_BUILD_ID ?? "undefined-build-id", + baseKey: key, + }; + } +} export default { - async get(cacheKey: string) { + async get(key: string) { try { + const cacheKey = getComposableCacheKey(key); // We first check if we have a pending write for this cache key // If we do, we return the pending promise instead of fetching the cache - if (pendingWritePromiseMap.has(cacheKey)) { - return pendingWritePromiseMap.get(cacheKey); + if (pendingWritePromiseMap.has(cacheKey.baseKey)) { + return pendingWritePromiseMap.get(cacheKey.baseKey); } - const result = await globalThis.incrementalCache.get( - cacheKey, - "composable", - ); + const result = await globalThis.incrementalCache.get(cacheKey); if (!result?.value?.value) { return undefined; } @@ -39,7 +65,7 @@ export default { ) { const hasBeenRevalidated = (await globalThis.tagCache.getLastModified( - cacheKey, + cacheKey.baseKey, result.lastModified, )) === -1; if (hasBeenRevalidated) return undefined; @@ -55,25 +81,24 @@ export default { } }, - async set(cacheKey: string, pendingEntry: Promise) { - pendingWritePromiseMap.set(cacheKey, pendingEntry); + async set(key: string, pendingEntry: Promise) { + const cacheKey = getComposableCacheKey(key); + pendingWritePromiseMap.set(cacheKey.baseKey, pendingEntry); const entry = await pendingEntry.finally(() => { - pendingWritePromiseMap.delete(cacheKey); + pendingWritePromiseMap.delete(cacheKey.baseKey); }); const valueToStore = await fromReadableStream(entry.value); - await globalThis.incrementalCache.set( - cacheKey, - { - ...entry, - value: valueToStore, - }, - "composable", - ); + await globalThis.incrementalCache.set(cacheKey, { + ...entry, + value: valueToStore, + }); if (globalThis.tagCache.mode === "original") { - const storedTags = await globalThis.tagCache.getByPath(cacheKey); + const storedTags = await globalThis.tagCache.getByPath(cacheKey.baseKey); const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag)); if (tagsToWrite.length > 0) { - await writeTags(tagsToWrite.map((tag) => ({ tag, path: cacheKey }))); + await writeTags( + tagsToWrite.map((tag) => ({ tag, path: cacheKey.baseKey })), + ); } } }, diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index 70dd083f8..0717acfb4 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -5,7 +5,11 @@ import type { InternalEvent, InternalResult } from "types/open-next"; import type { CacheValue } from "types/overrides"; import { emptyReadableStream, toReadableStream } from "utils/stream"; -import { getTagsFromValue, hasBeenRevalidated } from "utils/cache"; +import { + createCacheKey, + getTagsFromValue, + hasBeenRevalidated, +} from "utils/cache"; import { debug } from "../../adapters/logger"; import { localizePath } from "./i18n"; import { generateMessageGroupId } from "./queue"; @@ -208,7 +212,10 @@ export async function cacheInterceptor( if (isISR) { try { const cachedData = await globalThis.incrementalCache.get( - localizedPath ?? "/index", + createCacheKey({ + key: localizedPath ?? "/index", + type: "cache", + }), ); debug("cached data in interceptor", cachedData); diff --git a/packages/open-next/src/overrides/incrementalCache/fs-dev.ts b/packages/open-next/src/overrides/incrementalCache/fs-dev.ts index 6fe772f24..63f86943a 100644 --- a/packages/open-next/src/overrides/incrementalCache/fs-dev.ts +++ b/packages/open-next/src/overrides/incrementalCache/fs-dev.ts @@ -4,8 +4,7 @@ import path from "node:path"; import type { IncrementalCache } from "types/overrides.js"; import { getMonorepoRelativePath } from "utils/normalize-path"; -const buildId = process.env.NEXT_BUILD_ID; -const basePath = path.join(getMonorepoRelativePath(), `cache/${buildId}`); +const basePath = path.join(getMonorepoRelativePath(), "cache"); const getCacheKey = (key: string) => { return path.join(basePath, `${key}.cache`); @@ -13,24 +12,24 @@ const getCacheKey = (key: string) => { const cache: IncrementalCache = { name: "fs-dev", - get: async (key: string) => { - const fileData = await fs.readFile(getCacheKey(key), "utf-8"); + get: async ({ baseKey }) => { + const fileData = await fs.readFile(getCacheKey(baseKey), "utf-8"); const data = JSON.parse(fileData); - const { mtime } = await fs.stat(getCacheKey(key)); + const { mtime } = await fs.stat(getCacheKey(baseKey)); return { value: data, lastModified: mtime.getTime(), }; }, - set: async (key, value, isFetch) => { + set: async ({ baseKey }, value) => { const data = JSON.stringify(value); - const cacheKey = getCacheKey(key); + const cacheKey = getCacheKey(baseKey); // We need to create the directory before writing the file await fs.mkdir(path.dirname(cacheKey), { recursive: true }); await fs.writeFile(cacheKey, data); }, - delete: async (key) => { - await fs.rm(getCacheKey(key)); + delete: async ({ baseKey }) => { + await fs.rm(getCacheKey(baseKey)); }, }; diff --git a/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts b/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts index 5ee8bcc32..52aa4b178 100644 --- a/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts +++ b/packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts @@ -1,5 +1,6 @@ import type { CacheEntryType, + CacheKey, CacheValue, IncrementalCache, } from "types/overrides"; @@ -40,9 +41,8 @@ const awsFetch = (body: RequestInit["body"], type: "get" | "set" = "get") => { ); }; -const buildDynamoKey = (key: string) => { - const { NEXT_BUILD_ID } = process.env; - return `__meta_${NEXT_BUILD_ID}_${key}`; +const buildDynamoKey = (key: CacheKey) => { + return `__meta_${key.buildId ? `${key.buildId}_` : ""}${key.baseKey}`; }; /** @@ -55,11 +55,10 @@ const buildDynamoKey = (key: string) => { const multiTierCache: IncrementalCache = { name: "multi-tier-ddb-s3", async get( - key: string, - isFetch?: CacheType, + key: CacheKey, ) { // First we check the local cache - const localCacheEntry = localCache.get(key) as + const localCacheEntry = localCache.get(key.baseKey) as | { value: CacheValue; lastModified: number; @@ -87,7 +86,7 @@ const multiTierCache: IncrementalCache = { const data = await result.json(); const hasBeenDeleted = data.Item?.deleted?.BOOL; if (hasBeenDeleted) { - localCache.delete(key); + localCache.delete(key.baseKey); return { value: undefined, lastModified: 0 }; } // If the metadata is older than the local cache, we can use the local cache @@ -104,9 +103,9 @@ const multiTierCache: IncrementalCache = { debug("Failed to get metadata from ddb", e); } } - const result = await S3Cache.get(key, isFetch); + const result = await S3Cache.get(key); if (result?.value) { - localCache.set(key, { + localCache.set(key.baseKey, { value: result.value, lastModified: result.lastModified ?? Date.now(), }); @@ -117,9 +116,9 @@ const multiTierCache: IncrementalCache = { // Both for set and delete we choose to do the write to S3 first and then to DynamoDB // Which means that if it fails in DynamoDB, instance that don't have local cache will work as expected. // But instance that have local cache will have a stale cache until the next working set or delete. - async set(key, value, isFetch) { + async set(key, value) { const revalidatedAt = Date.now(); - await S3Cache.set(key, value, isFetch); + await S3Cache.set(key, value); await awsFetch( JSON.stringify({ TableName: process.env.CACHE_DYNAMO_TABLE, @@ -131,7 +130,7 @@ const multiTierCache: IncrementalCache = { }), "set", ); - localCache.set(key, { + localCache.set(key.baseKey, { value, lastModified: revalidatedAt, }); @@ -149,7 +148,7 @@ const multiTierCache: IncrementalCache = { }), "set", ); - localCache.delete(key); + localCache.delete(key.baseKey); }, }; diff --git a/packages/open-next/src/overrides/incrementalCache/s3-lite.ts b/packages/open-next/src/overrides/incrementalCache/s3-lite.ts index e2355be34..6dc0a3648 100644 --- a/packages/open-next/src/overrides/incrementalCache/s3-lite.ts +++ b/packages/open-next/src/overrides/incrementalCache/s3-lite.ts @@ -2,8 +2,7 @@ import path from "node:path"; import { AwsClient } from "aws4fetch"; -import type { Extension } from "types/cache"; -import type { IncrementalCache } from "types/overrides"; +import type { CacheKey, IncrementalCache } from "types/overrides"; import { IgnorableError, RecoverableError } from "utils/error"; import { customFetchClient } from "utils/fetch"; @@ -33,19 +32,19 @@ const awsFetch = async (key: string, options: RequestInit) => { return customFetchClient(client)(url, options); }; -function buildS3Key(key: string, extension: Extension) { - const { CACHE_BUCKET_KEY_PREFIX, NEXT_BUILD_ID } = process.env; +function buildS3Key(key: CacheKey) { return path.posix.join( - CACHE_BUCKET_KEY_PREFIX ?? "", - extension === "fetch" ? "__fetch" : "", - NEXT_BUILD_ID ?? "", - extension === "fetch" ? key : `${key}.${extension}`, + process.env.CACHE_BUCKET_KEY_PREFIX ?? "", + key.cacheType === "fetch" ? "__fetch" : "", + key.cacheType === "fetch" + ? key.baseKey + : `${key.buildId ? `${key.buildId}/` : ""}${key.baseKey}.${key.cacheType}`, ); } const incrementalCache: IncrementalCache = { - async get(key, cacheType) { - const result = await awsFetch(buildS3Key(key, cacheType ?? "cache"), { + async get(key) { + const result = await awsFetch(buildS3Key(key), { method: "GET", }); @@ -63,8 +62,8 @@ const incrementalCache: IncrementalCache = { ).getTime(), }; }, - async set(key, value, cacheType): Promise { - const response = await awsFetch(buildS3Key(key, cacheType ?? "cache"), { + async set(key, value): Promise { + const response = await awsFetch(buildS3Key(key), { method: "PUT", body: JSON.stringify(value), }); @@ -73,7 +72,7 @@ const incrementalCache: IncrementalCache = { } }, async delete(key): Promise { - const response = await awsFetch(buildS3Key(key, "cache"), { + const response = await awsFetch(buildS3Key(key), { method: "DELETE", }); if (response.status !== 204) { diff --git a/packages/open-next/src/overrides/incrementalCache/s3.ts b/packages/open-next/src/overrides/incrementalCache/s3.ts index 371499209..6bf6a7c91 100644 --- a/packages/open-next/src/overrides/incrementalCache/s3.ts +++ b/packages/open-next/src/overrides/incrementalCache/s3.ts @@ -7,18 +7,13 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; -import type { Extension } from "types/cache"; -import type { IncrementalCache } from "types/overrides"; +import type { CacheKey, IncrementalCache } from "types/overrides"; import { awsLogger } from "../../adapters/logger"; import { parseNumberFromEnv } from "../../adapters/util"; -const { - CACHE_BUCKET_REGION, - CACHE_BUCKET_KEY_PREFIX, - NEXT_BUILD_ID, - CACHE_BUCKET_NAME, -} = process.env; +const { CACHE_BUCKET_REGION, CACHE_BUCKET_KEY_PREFIX, CACHE_BUCKET_NAME } = + process.env; function parseS3ClientConfigFromEnv(): S3ClientConfig { return { @@ -30,21 +25,20 @@ function parseS3ClientConfigFromEnv(): S3ClientConfig { const s3Client = new S3Client(parseS3ClientConfigFromEnv()); -function buildS3Key(key: string, extension: Extension) { +function buildS3Key(key: CacheKey) { return path.posix.join( CACHE_BUCKET_KEY_PREFIX ?? "", - extension === "fetch" ? "__fetch" : "", - NEXT_BUILD_ID ?? "", - extension === "fetch" ? key : `${key}.${extension}`, + key.cacheType === "fetch" ? "__fetch" : "", + `${key.buildId ? `${key.buildId}/` : ""}${key.baseKey}.${key.cacheType}`, ); } const incrementalCache: IncrementalCache = { - async get(key, cacheType) { + async get(key) { const result = await s3Client.send( new GetObjectCommand({ Bucket: CACHE_BUCKET_NAME, - Key: buildS3Key(key, cacheType ?? "cache"), + Key: buildS3Key(key), }), ); @@ -56,11 +50,11 @@ const incrementalCache: IncrementalCache = { lastModified: result.LastModified?.getTime(), }; }, - async set(key, value, cacheType): Promise { + async set(key, value): Promise { await s3Client.send( new PutObjectCommand({ Bucket: CACHE_BUCKET_NAME, - Key: buildS3Key(key, cacheType ?? "cache"), + Key: buildS3Key(key), Body: JSON.stringify(value), }), ); @@ -69,7 +63,7 @@ const incrementalCache: IncrementalCache = { await s3Client.send( new DeleteObjectCommand({ Bucket: CACHE_BUCKET_NAME, - Key: buildS3Key(key, "cache"), + Key: buildS3Key(key), }), ); }, diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index 8de290285..1c18db340 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -78,6 +78,16 @@ export interface DangerousOptions { headersAndCookiesPriority?: ( event: InternalEvent, ) => "middleware" | "handler"; + + /** + * Persist data cache between deployments. + * Next.js claims that the data cache is persistent (not true for `use cache` and it depends on how you build/deploy otherwise). + * By default, every entry will be prepended with the BUILD_ID, when enabled it will not. + * This means that the data cache will be persistent between deployments. + * This is useful in a lot of cases, but be aware that it could cause issues, especially with `use cache` or `unstable_cache` (Some external change may not be reflected in the key, leading to stale data) + * @default false + */ + persistentDataCache?: boolean; } export type BaseOverride = { diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index e6ea87120..aab3c4466 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -109,17 +109,30 @@ export type CacheValue = revalidate?: number | false; }; +/** + * Represents a cache key used in the incremental cache. + * Depending on the `dangerous.persistentDataCache` setting, the key may include the build ID. + * If `persistentDataCache` is enabled, the key will not include the build ID for data cache entries + */ +export type CacheKey = { + cacheType: CacheType; + buildId: CacheType extends "cache" ? string : string | undefined; + /** + * The base key is the main identifier for the cache entry. + * It never depends on the build ID, and is used to identify the cache entry. + */ + baseKey: string; +}; + export type IncrementalCache = { get( - key: string, - cacheType?: CacheType, + key: CacheKey, ): Promise> | null>; set( - key: string, + key: CacheKey, value: CacheValue, - isFetch?: CacheType, ): Promise; - delete(key: string): Promise; + delete(key: CacheKey<"cache">): Promise; name: string; }; diff --git a/packages/open-next/src/utils/cache.ts b/packages/open-next/src/utils/cache.ts index bfc2fd781..4d1182c9b 100644 --- a/packages/open-next/src/utils/cache.ts +++ b/packages/open-next/src/utils/cache.ts @@ -1,4 +1,6 @@ import type { + CacheEntryType, + CacheKey, CacheValue, OriginalTagCacheWriteInput, WithLastModified, @@ -80,3 +82,34 @@ export async function writeTags( // Here we know that we have the correct type await globalThis.tagCache.writeTags(tagsToWrite as any); } + +export function createCacheKey({ + key, + type, +}: { key: string; type: CacheType }): CacheKey { + // We always prepend the build ID to the cache key for ISR/SSG cache entry + // For data cache, we only prepend the build ID if the persistentDataCache is not enabled + const shouldPrependBuildId = + globalThis.openNextConfig.dangerous?.persistentDataCache !== true; + const buildId = process.env.NEXT_BUILD_ID ?? "undefined-build-id"; + // ISR/SSG cache entry should always have a build ID + if (type === "cache") { + return { + cacheType: "cache", + buildId, + baseKey: key, + } as CacheKey; + } + if (shouldPrependBuildId) { + return { + cacheType: type, + buildId, + baseKey: key, + }; + } + return { + cacheType: type, + buildId: undefined, + baseKey: key, + } as CacheKey; +} diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index af507200c..fd4d93a9d 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -4,7 +4,11 @@ import { vi } from "vitest"; declare global { var openNextConfig: { - dangerous: { disableIncrementalCache?: boolean; disableTagCache?: boolean }; + dangerous: { + disableIncrementalCache?: boolean; + disableTagCache?: boolean; + persistentDataCache?: boolean; + }; }; var isNextAfter15: boolean; } @@ -360,9 +364,12 @@ describe("CacheHandler", () => { }); expect(incrementalCache.set).toHaveBeenCalledWith( - "key", + { + baseKey: "key", + cacheType: "cache", + buildId: "undefined-build-id", + }, { type: "route", body: "{}", meta: { status: 200, headers: {} } }, - "cache", ); }); @@ -377,13 +384,16 @@ describe("CacheHandler", () => { }); expect(incrementalCache.set).toHaveBeenCalledWith( - "key", + { + baseKey: "key", + cacheType: "cache", + buildId: "undefined-build-id", + }, { type: "route", body: Buffer.from("{}").toString("base64"), meta: { status: 200, headers: { "content-type": "image/png" } }, }, - "cache", ); }); @@ -397,13 +407,16 @@ describe("CacheHandler", () => { }); expect(incrementalCache.set).toHaveBeenCalledWith( - "key", + { + baseKey: "key", + cacheType: "cache", + buildId: "undefined-build-id", + }, { type: "page", html: "", json: {}, }, - "cache", ); }); @@ -417,14 +430,17 @@ describe("CacheHandler", () => { }); expect(incrementalCache.set).toHaveBeenCalledWith( - "key", + { + baseKey: "key", + cacheType: "cache", + buildId: "undefined-build-id", + }, { type: "app", html: "", rsc: "rsc", meta: { status: 200, headers: {} }, }, - "cache", ); }); @@ -438,14 +454,17 @@ describe("CacheHandler", () => { }); expect(incrementalCache.set).toHaveBeenCalledWith( - "key", + { + baseKey: "key", + cacheType: "cache", + buildId: "undefined-build-id", + }, { type: "app", html: "", rsc: "rsc", meta: { status: 200, headers: {} }, }, - "cache", ); }); @@ -463,7 +482,11 @@ describe("CacheHandler", () => { }); expect(incrementalCache.set).toHaveBeenCalledWith( - "key", + { + baseKey: "key", + cacheType: "fetch", + buildId: "undefined-build-id", + }, { kind: "FETCH", data: { @@ -475,7 +498,6 @@ describe("CacheHandler", () => { }, revalidate: 60, }, - "fetch", ); }); @@ -483,12 +505,15 @@ describe("CacheHandler", () => { await cache.set("key", { kind: "REDIRECT", props: {} }); expect(incrementalCache.set).toHaveBeenCalledWith( - "key", + { + baseKey: "key", + cacheType: "cache", + buildId: "undefined-build-id", + }, { type: "redirect", props: {}, }, - "cache", ); }); diff --git a/packages/tests-unit/tests/utils/cache.test.ts b/packages/tests-unit/tests/utils/cache.test.ts new file mode 100644 index 000000000..cc5681fd3 --- /dev/null +++ b/packages/tests-unit/tests/utils/cache.test.ts @@ -0,0 +1,62 @@ +import { createCacheKey } from "@opennextjs/aws/utils/cache.js"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +describe("createCacheKey", () => { + const originalEnv = process.env; + const originalGlobalThis = globalThis as any; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + + // Mock globalThis.openNextConfig + if (!globalThis.openNextConfig) { + globalThis.openNextConfig = { + dangerous: {}, + }; + } + }); + + afterEach(() => { + process.env = originalEnv; + globalThis.openNextConfig = originalGlobalThis.openNextConfig; + }); + + test("have a defined build id for non-data cache entries", () => { + process.env.NEXT_BUILD_ID = "test-build-id"; + const key = "test-key"; + + const result = createCacheKey({ key, type: "cache" }); + + expect(result.buildId).toBe("test-build-id"); + }); + + test("have a defined build id for data cache when persistentDataCache is not enabled", () => { + process.env.NEXT_BUILD_ID = "test-build-id"; + globalThis.openNextConfig.dangerous.persistentDataCache = false; + const key = "test-key"; + + const result = createCacheKey({ key, type: "fetch" }); + + expect(result.buildId).toBe("test-build-id"); + }); + + test("does not prepend build ID for data cache when persistentDataCache is enabled", () => { + process.env.NEXT_BUILD_ID = "test-build-id"; + globalThis.openNextConfig.dangerous.persistentDataCache = true; + const key = "test-key"; + + const result = createCacheKey({ key, type: "fetch" }); + + expect(result.buildId).toBeUndefined(); + }); + + test("handles missing build ID", () => { + process.env.NEXT_BUILD_ID = undefined; + const key = "test-key"; + + const result = createCacheKey({ key, type: "fetch" }); + + expect(result.buildId).toBeUndefined(); + }); +});