diff --git a/.changeset/sour-pandas-buy.md b/.changeset/sour-pandas-buy.md new file mode 100644 index 000000000..228a050fb --- /dev/null +++ b/.changeset/sour-pandas-buy.md @@ -0,0 +1,31 @@ +--- +"@opennextjs/aws": minor +--- + +Introduce support for the composable cache + +BREAKING CHANGE: The interface for the Incremental cache has changed. The new interface use a Cache type instead of a boolean to distinguish between the different types of caches. It also includes a new Cache type for the composable cache. The new interface is as follows: + +```ts +export type CacheEntryType = "cache" | "fetch" | "composable"; + +export type IncrementalCache = { + get( + key: string, + cacheType?: CacheType, + ): Promise> | null>; + set( + key: string, + value: CacheValue, + isFetch?: CacheType, + ): Promise; + delete(key: string): Promise; + name: string; +}; +``` + +NextModeTagCache also get a new function `getLastRevalidated` used for the composable cache: + +```ts + getLastRevalidated(tags: string[]): Promise; +``` \ No newline at end of file diff --git a/examples/experimental/next.config.ts b/examples/experimental/next.config.ts index c3fcdb402..c6ebc28eb 100644 --- a/examples/experimental/next.config.ts +++ b/examples/experimental/next.config.ts @@ -10,6 +10,7 @@ const nextConfig: NextConfig = { experimental: { ppr: "incremental", nodeMiddleware: true, + dynamicIO: true, }, }; diff --git a/examples/experimental/src/app/api/revalidate/route.ts b/examples/experimental/src/app/api/revalidate/route.ts new file mode 100644 index 000000000..da6b1e027 --- /dev/null +++ b/examples/experimental/src/app/api/revalidate/route.ts @@ -0,0 +1,6 @@ +import { revalidateTag } from "next/cache"; + +export function GET() { + revalidateTag("fullyTagged"); + return new Response("DONE"); +} diff --git a/examples/experimental/src/app/use-cache/isr/page.tsx b/examples/experimental/src/app/use-cache/isr/page.tsx new file mode 100644 index 000000000..dd02f8a63 --- /dev/null +++ b/examples/experimental/src/app/use-cache/isr/page.tsx @@ -0,0 +1,17 @@ +import { FullyCachedComponent, ISRComponent } from "@/components/cached"; +import { Suspense } from "react"; + +export default async function Page() { + // Not working for now, need a patch in next to disable full revalidation during ISR revalidation + return ( +
+

Cache

+ Loading...

}> + +
+ Loading...

}> + +
+
+ ); +} diff --git a/examples/experimental/src/app/use-cache/layout.tsx b/examples/experimental/src/app/use-cache/layout.tsx new file mode 100644 index 000000000..b21b82fe6 --- /dev/null +++ b/examples/experimental/src/app/use-cache/layout.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react"; + +export default function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ Loading...

}>{children}
+
+ ); +} diff --git a/examples/experimental/src/app/use-cache/ssr/page.tsx b/examples/experimental/src/app/use-cache/ssr/page.tsx new file mode 100644 index 000000000..41a413d1b --- /dev/null +++ b/examples/experimental/src/app/use-cache/ssr/page.tsx @@ -0,0 +1,20 @@ +import { FullyCachedComponent, ISRComponent } from "@/components/cached"; +import { headers } from "next/headers"; +import { Suspense } from "react"; + +export default async function Page() { + // To opt into SSR + const _headers = await headers(); + return ( +
+

Cache

+

{_headers.get("accept") ?? "No accept headers"}

+ Loading...

}> + +
+ Loading...

}> + +
+
+ ); +} diff --git a/examples/experimental/src/components/cached.tsx b/examples/experimental/src/components/cached.tsx new file mode 100644 index 000000000..7abaa010c --- /dev/null +++ b/examples/experimental/src/components/cached.tsx @@ -0,0 +1,24 @@ +import { unstable_cacheLife, unstable_cacheTag } from "next/cache"; + +export async function FullyCachedComponent() { + "use cache"; + unstable_cacheTag("fullyTagged"); + return ( +
+

{Date.now()}

+
+ ); +} + +export async function ISRComponent() { + "use cache"; + unstable_cacheLife({ + stale: 1, + revalidate: 5, + }); + return ( +
+

{Date.now()}

+
+ ); +} diff --git a/packages/open-next/src/adapters/cache.ts b/packages/open-next/src/adapters/cache.ts index e7ab10157..48a9d067d 100644 --- a/packages/open-next/src/adapters/cache.ts +++ b/packages/open-next/src/adapters/cache.ts @@ -57,7 +57,7 @@ export default class Cache { async getFetchCache(key: string, softTags?: string[], tags?: string[]) { debug("get fetch cache", { key, softTags, tags }); try { - const cachedEntry = await globalThis.incrementalCache.get(key, true); + const cachedEntry = await globalThis.incrementalCache.get(key, "fetch"); if (cachedEntry?.value === undefined) return null; @@ -107,7 +107,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, false); + const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -227,7 +227,7 @@ export default class Cache { }, revalidate, }, - false, + "cache", ); break; } @@ -248,7 +248,7 @@ export default class Cache { }, revalidate, }, - false, + "cache", ); } else { await globalThis.incrementalCache.set( @@ -259,7 +259,7 @@ export default class Cache { json: pageData, revalidate, }, - false, + "cache", ); } break; @@ -278,12 +278,12 @@ export default class Cache { }, revalidate, }, - false, + "cache", ); break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, true); + await globalThis.incrementalCache.set(key, data, "fetch"); break; case "REDIRECT": await globalThis.incrementalCache.set( @@ -293,7 +293,7 @@ export default class Cache { props: data.props, revalidate, }, - false, + "cache", ); break; case "IMAGE": diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts new file mode 100644 index 000000000..00b3847bd --- /dev/null +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -0,0 +1,115 @@ +import type { ComposableCacheEntry, ComposableCacheHandler } from "types/cache"; +import { fromReadableStream, toReadableStream } from "utils/stream"; +import { debug } from "./logger"; + +export default { + async get(cacheKey: string) { + try { + const result = await globalThis.incrementalCache.get( + cacheKey, + "composable", + ); + if (!result?.value?.value) { + return undefined; + } + + debug("composable cache result", result); + + // We need to check if the tags associated with this entry has been revalidated + if ( + globalThis.tagCache.mode === "nextMode" && + result.value.tags.length > 0 + ) { + const hasBeenRevalidated = await globalThis.tagCache.hasBeenRevalidated( + result.value.tags, + result.lastModified, + ); + if (hasBeenRevalidated) return undefined; + } else if ( + globalThis.tagCache.mode === "original" || + globalThis.tagCache.mode === undefined + ) { + const hasBeenRevalidated = + (await globalThis.tagCache.getLastModified( + cacheKey, + result.lastModified, + )) === -1; + if (hasBeenRevalidated) return undefined; + } + + return { + ...result.value, + value: toReadableStream(result.value.value), + }; + } catch (e) { + debug("Cannot read composable cache entry"); + return undefined; + } + }, + + async set(cacheKey: string, pendingEntry: Promise) { + const entry = await pendingEntry; + const valueToStore = await fromReadableStream(entry.value); + await globalThis.incrementalCache.set( + cacheKey, + { + ...entry, + value: valueToStore, + }, + "composable", + ); + if (globalThis.tagCache.mode === "original") { + const storedTags = await globalThis.tagCache.getByPath(cacheKey); + const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag)); + if (tagsToWrite.length > 0) { + await globalThis.tagCache.writeTags( + tagsToWrite.map((tag) => ({ tag, path: cacheKey })), + ); + } + } + }, + + async refreshTags() { + // We don't do anything for now, do we want to do something here ??? + return; + }, + async getExpiration(...tags: string[]) { + if (globalThis.tagCache.mode === "nextMode") { + return globalThis.tagCache.getLastRevalidated(tags); + } + // We always return 0 here, original tag cache are handled directly in the get part + // TODO: We need to test this more, i'm not entirely sure that this is working as expected + return 0; + }, + async expireTags(...tags: string[]) { + if (globalThis.tagCache.mode === "nextMode") { + return globalThis.tagCache.writeTags(tags); + } + const tagCache = globalThis.tagCache; + const revalidatedAt = Date.now(); + // For the original mode, we have more work to do here. + // We need to find all paths linked to to these tags + const pathsToUpdate = await Promise.all( + tags.map(async (tag) => { + const paths = await tagCache.getByTag(tag); + return paths.map((path) => ({ + path, + tag, + revalidatedAt, + })); + }), + ); + // We need to deduplicate paths, we use a set for that + const setToWrite = new Set<{ path: string; tag: string }>(); + for (const entry of pathsToUpdate.flat()) { + setToWrite.add(entry); + } + await globalThis.tagCache.writeTags(Array.from(setToWrite)); + }, + + // This one is necessary for older versions of next + async receiveExpiredTags(...tags: string[]) { + // This function does absolutely nothing + return; + }, +} satisfies ComposableCacheHandler; diff --git a/packages/open-next/src/build/compileCache.ts b/packages/open-next/src/build/compileCache.ts index fa30f2e3a..7e390fdf5 100644 --- a/packages/open-next/src/build/compileCache.ts +++ b/packages/open-next/src/build/compileCache.ts @@ -7,7 +7,7 @@ import * as buildHelper from "./helper.js"; * * @param options Build options. * @param format Output format. - * @returns The path to the compiled file. + * @returns An object containing the paths to the compiled cache and composable cache files. */ export function compileCache( options: buildHelper.BuildOptions, @@ -15,7 +15,7 @@ export function compileCache( ) { const { config } = options; const ext = format === "cjs" ? "cjs" : "mjs"; - const outFile = path.join(options.buildDir, `cache.${ext}`); + const compiledCacheFile = path.join(options.buildDir, `cache.${ext}`); const isAfter15 = buildHelper.compareSemver( options.nextVersion, @@ -23,11 +23,12 @@ export function compileCache( "15.0.0", ); + // Normal cache buildHelper.esbuildSync( { external: ["next", "styled-jsx", "react", "@aws-sdk/*"], entryPoints: [path.join(options.openNextDistDir, "adapters", "cache.js")], - outfile: outFile, + outfile: compiledCacheFile, target: ["node18"], format, banner: { @@ -44,5 +45,39 @@ export function compileCache( }, options, ); - return outFile; + + const compiledComposableCacheFile = path.join( + options.buildDir, + `composable-cache.${ext}`, + ); + + // Composable cache + buildHelper.esbuildSync( + { + external: ["next", "styled-jsx", "react", "@aws-sdk/*"], + entryPoints: [ + path.join(options.openNextDistDir, "adapters", "composable-cache.js"), + ], + outfile: compiledComposableCacheFile, + target: ["node18"], + format, + banner: { + js: [ + `globalThis.disableIncrementalCache = ${ + config.dangerous?.disableIncrementalCache ?? false + };`, + `globalThis.disableDynamoDBCache = ${ + config.dangerous?.disableTagCache ?? false + };`, + `globalThis.isNextAfter15 = ${isAfter15};`, + ].join(""), + }, + }, + options, + ); + + return { + cache: compiledCacheFile, + composableCache: compiledComposableCacheFile, + }; } diff --git a/packages/open-next/src/build/createServerBundle.ts b/packages/open-next/src/build/createServerBundle.ts index 694ef50c9..3582f5549 100644 --- a/packages/open-next/src/build/createServerBundle.ts +++ b/packages/open-next/src/build/createServerBundle.ts @@ -28,6 +28,7 @@ import { patchFetchCacheSetMissingWaitUntil, patchNextServer, patchUnstableCacheForISR, + patchUseCacheForISR, } from "./patch/patches/index.js"; interface CodeCustomization { @@ -147,10 +148,16 @@ async function generateBundle( fs.mkdirSync(outPackagePath, { recursive: true }); const ext = fnOptions.runtime === "deno" ? "mjs" : "cjs"; + // Normal cache fs.copyFileSync( path.join(options.buildDir, `cache.${ext}`), path.join(outPackagePath, "cache.cjs"), ); + // Composable cache + fs.copyFileSync( + path.join(options.buildDir, `composable-cache.${ext}`), + path.join(outPackagePath, "composable-cache.cjs"), + ); if (fnOptions.runtime === "deno") { addDenoJson(outputPath, packagePath); @@ -206,6 +213,7 @@ async function generateBundle( patchNextServer, patchEnvVars, patchBackgroundRevalidation, + patchUseCacheForISR, ...additionalCodePatches, ]); @@ -237,6 +245,12 @@ async function generateBundle( "14.2", ); + const isAfter152 = buildHelper.compareSemver( + options.nextVersion, + ">=", + "15.2.0", + ); + const disableRouting = isBefore13413 || config.middleware?.external; const updater = new ContentUpdater(options); @@ -265,6 +279,7 @@ async function generateBundle( ...(isAfter141 ? ["experimentalIncrementalCacheHandler"] : ["stableIncrementalCache"]), + ...(isAfter152 ? [] : ["composableCache"]), ], }), diff --git a/packages/open-next/src/build/patch/patches/index.ts b/packages/open-next/src/build/patch/patches/index.ts index 22ef817ba..bd46d6532 100644 --- a/packages/open-next/src/build/patch/patches/index.ts +++ b/packages/open-next/src/build/patch/patches/index.ts @@ -3,6 +3,7 @@ export { patchNextServer } from "./patchNextServer.js"; export { patchFetchCacheForISR, patchUnstableCacheForISR, + patchUseCacheForISR, } from "./patchFetchCacheISR.js"; export { patchFetchCacheSetMissingWaitUntil } from "./patchFetchCacheWaitUntil.js"; export { patchBackgroundRevalidation } from "./patchBackgroundRevalidation.js"; diff --git a/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts b/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts index 17ccea628..d117045cb 100644 --- a/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts +++ b/packages/open-next/src/build/patch/patches/patchFetchCacheISR.ts @@ -78,6 +78,29 @@ fix: ($STORE_OR_CACHE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) `; +export const useCacheRule = ` +rule: + kind: member_expression + pattern: $STORE_OR_CACHE.isOnDemandRevalidate + inside: + kind: binary_expression + has: + kind: member_expression + pattern: $STORE_OR_CACHE.isDraftMode + inside: + kind: if_statement + stopBy: end + has: + kind: return_statement + any: + - has: + kind: 'true' + - has: + regex: '!0' + stopBy: end +fix: + '($STORE_OR_CACHE.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)'`; + export const patchFetchCacheForISR: CodePatcher = { name: "patch-fetch-cache-for-isr", patches: [ @@ -111,3 +134,20 @@ export const patchUnstableCacheForISR: CodePatcher = { }, ], }; + +export const patchUseCacheForISR: CodePatcher = { + name: "patch-use-cache-for-isr", + patches: [ + { + versions: ">=15.3.0", + field: { + pathFilter: getCrossPlatformPathRegex( + String.raw`(server/chunks/.*\.js|\.runtime\..*\.js|use-cache/use-cache-wrapper\.js)$`, + { escape: false }, + ), + contentFilter: /\.isOnDemandRevalidate/, + patchCode: createPatchCode(useCacheRule, Lang.JavaScript), + }, + }, + ], +}; diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index bb8814f12..9a2aaf88c 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -89,7 +89,7 @@ async function computeCacheControl( async function generateResult( event: InternalEvent, localizedPath: string, - cachedValue: CacheValue, + cachedValue: CacheValue<"cache">, lastModified?: number, ): Promise { debug("Returning result from experimental cache"); diff --git a/packages/open-next/src/core/util.ts b/packages/open-next/src/core/util.ts index 876710d2a..d6b89d38a 100644 --- a/packages/open-next/src/core/util.ts +++ b/packages/open-next/src/core/util.ts @@ -25,6 +25,7 @@ overrideNextjsRequireHooks(NextConfig); applyNextjsRequireHooksOverride(); //#endOverride const cacheHandlerPath = require.resolve("./cache.cjs"); +const composableCacheHandlerPath = require.resolve("./composable-cache.cjs"); // @ts-ignore const nextServer = new NextServer.default({ //#override requestHandlerHost @@ -52,6 +53,12 @@ const nextServer = new NextServer.default({ //#override experimentalIncrementalCacheHandler incrementalCacheHandlerPath: cacheHandlerPath, //#endOverride + + //#override composableCache + cacheHandlers: { + default: composableCacheHandlerPath, + }, + //#endOverride }, }, customServer: false, 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 49c254258..5ee8bcc32 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,4 +1,8 @@ -import type { CacheValue, IncrementalCache } from "types/overrides"; +import type { + CacheEntryType, + CacheValue, + IncrementalCache, +} from "types/overrides"; import { customFetchClient } from "utils/fetch"; import { LRUCache } from "utils/lru"; import { debug } from "../../adapters/logger"; @@ -50,11 +54,14 @@ const buildDynamoKey = (key: string) => { */ const multiTierCache: IncrementalCache = { name: "multi-tier-ddb-s3", - async get(key: string, isFetch?: IsFetch) { + async get( + key: string, + isFetch?: CacheType, + ) { // First we check the local cache const localCacheEntry = localCache.get(key) as | { - value: CacheValue; + value: CacheValue; lastModified: number; } | undefined; diff --git a/packages/open-next/src/overrides/incrementalCache/s3-lite.ts b/packages/open-next/src/overrides/incrementalCache/s3-lite.ts index e24db620a..e2355be34 100644 --- a/packages/open-next/src/overrides/incrementalCache/s3-lite.ts +++ b/packages/open-next/src/overrides/incrementalCache/s3-lite.ts @@ -44,13 +44,10 @@ function buildS3Key(key: string, extension: Extension) { } const incrementalCache: IncrementalCache = { - async get(key, isFetch) { - const result = await awsFetch( - buildS3Key(key, isFetch ? "fetch" : "cache"), - { - method: "GET", - }, - ); + async get(key, cacheType) { + const result = await awsFetch(buildS3Key(key, cacheType ?? "cache"), { + method: "GET", + }); if (result.status === 404) { throw new IgnorableError("Not found"); @@ -66,14 +63,11 @@ const incrementalCache: IncrementalCache = { ).getTime(), }; }, - async set(key, value, isFetch): Promise { - const response = await awsFetch( - buildS3Key(key, isFetch ? "fetch" : "cache"), - { - method: "PUT", - body: JSON.stringify(value), - }, - ); + async set(key, value, cacheType): Promise { + const response = await awsFetch(buildS3Key(key, cacheType ?? "cache"), { + method: "PUT", + body: JSON.stringify(value), + }); if (response.status !== 200) { throw new RecoverableError(`Failed to set cache: ${response.status}`); } diff --git a/packages/open-next/src/overrides/incrementalCache/s3.ts b/packages/open-next/src/overrides/incrementalCache/s3.ts index 0ee7f6b51..371499209 100644 --- a/packages/open-next/src/overrides/incrementalCache/s3.ts +++ b/packages/open-next/src/overrides/incrementalCache/s3.ts @@ -40,11 +40,11 @@ function buildS3Key(key: string, extension: Extension) { } const incrementalCache: IncrementalCache = { - async get(key, isFetch) { + async get(key, cacheType) { const result = await s3Client.send( new GetObjectCommand({ Bucket: CACHE_BUCKET_NAME, - Key: buildS3Key(key, isFetch ? "fetch" : "cache"), + Key: buildS3Key(key, cacheType ?? "cache"), }), ); @@ -56,11 +56,11 @@ const incrementalCache: IncrementalCache = { lastModified: result.LastModified?.getTime(), }; }, - async set(key, value, isFetch): Promise { + async set(key, value, cacheType): Promise { await s3Client.send( new PutObjectCommand({ Bucket: CACHE_BUCKET_NAME, - Key: buildS3Key(key, isFetch ? "fetch" : "cache"), + Key: buildS3Key(key, cacheType ?? "cache"), Body: JSON.stringify(value), }), ); diff --git a/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts index 7d16bebfc..1d4a5da38 100644 --- a/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts @@ -71,6 +71,10 @@ function buildDynamoObject(tag: string, revalidatedAt?: number) { export default { name: "ddb-nextMode", mode: "nextMode", + getLastRevalidated: async (tags: string[]) => { + // Not supported for now + return 0; + }, hasBeenRevalidated: async (tags: string[], lastModified?: number) => { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; diff --git a/packages/open-next/src/overrides/tagCache/fs-dev.ts b/packages/open-next/src/overrides/tagCache/fs-dev.ts index 4de4d0a75..f6f61e992 100644 --- a/packages/open-next/src/overrides/tagCache/fs-dev.ts +++ b/packages/open-next/src/overrides/tagCache/fs-dev.ts @@ -2,6 +2,7 @@ import type { TagCache } from "types/overrides"; import fs from "node:fs"; +// TODO: fix this for monorepo const tagFile = "../../dynamodb-provider/dynamodb-cache.json"; const tagContent = fs.readFileSync(tagFile, "utf-8"); @@ -44,7 +45,7 @@ const tagCache: TagCache = { newTags.map((tag) => ({ tag: { S: tag.tag }, path: { S: tag.path }, - revalidatedAt: { N: String(tag.revalidatedAt) }, + revalidatedAt: { N: String(tag.revalidatedAt ?? 1) }, })), ); }, diff --git a/packages/open-next/src/types/cache.ts b/packages/open-next/src/types/cache.ts index ca3c4ffde..26b5b396e 100644 --- a/packages/open-next/src/types/cache.ts +++ b/packages/open-next/src/types/cache.ts @@ -1,3 +1,5 @@ +import type { ReadableStream } from "node:stream/web"; + interface CachedFetchValue { kind: "FETCH"; data: { @@ -79,7 +81,7 @@ export interface CacheHandlerValue { value: IncrementalCacheValue | null; } -export type Extension = "cache" | "fetch"; +export type Extension = "cache" | "fetch" | "composable"; type MetaHeaders = { "x-next-cache-tags"?: string; @@ -139,3 +141,31 @@ export type IncrementalCacheContext = | SetIncrementalCacheContext | SetIncrementalFetchCacheContext | SetIncrementalResponseCacheContext; + +export interface ComposableCacheEntry { + value: ReadableStream; + tags: string[]; + stale: number; + timestamp: number; + expire: number; + revalidate: number; +} + +export type StoredComposableCacheEntry = Omit & { + value: string; +}; + +export interface ComposableCacheHandler { + get(cacheKey: string): Promise; + set( + cacheKey: string, + pendingEntry: Promise, + ): Promise; + refreshTags(): Promise; + getExpiration(...tags: string[]): Promise; + expireTags(...tags: string[]): Promise; + /** + * This function is only there for older versions and do nothing + */ + receiveExpiredTags(...tags: string[]): Promise; +} diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 8363835c4..4d8eb02d9 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -1,6 +1,7 @@ import type { Readable } from "node:stream"; -import type { Meta } from "types/cache"; +import type { Extension, Meta, StoredComposableCacheEntry } from "types/cache"; + import type { BaseEventOrResult, BaseOverride, @@ -77,24 +78,29 @@ export type WithLastModified = { value?: T; }; -export type CacheValue = (IsFetch extends true - ? CachedFetchValue - : CachedFile) & { - /** - * This is available for page cache entry, but only at runtime. - */ - revalidate?: number | false; -}; +export type CacheEntryType = Extension; + +export type CacheValue = + (CacheType extends "fetch" + ? CachedFetchValue + : CacheType extends "cache" + ? CachedFile + : StoredComposableCacheEntry) & { + /** + * This is available for page cache entry, but only at runtime. + */ + revalidate?: number | false; + }; export type IncrementalCache = { - get( + get( key: string, - isFetch?: IsFetch, - ): Promise> | null>; - set( + cacheType?: CacheType, + ): Promise> | null>; + set( key: string, - value: CacheValue, - isFetch?: IsFetch, + value: CacheValue, + isFetch?: CacheType, ): Promise; delete(key: string): Promise; name: string; @@ -129,6 +135,8 @@ Cons : */ export type NextModeTagCache = BaseTagCache & { mode: "nextMode"; + // Necessary for the composable cache + getLastRevalidated(tags: string[]): Promise; hasBeenRevalidated(tags: string[], lastModified?: number): Promise; writeTags(tags: string[]): Promise; // Optional method to get paths by tags diff --git a/packages/open-next/src/utils/cache.ts b/packages/open-next/src/utils/cache.ts index 98c028004..5072e4bef 100644 --- a/packages/open-next/src/utils/cache.ts +++ b/packages/open-next/src/utils/cache.ts @@ -28,7 +28,7 @@ export async function hasBeenRevalidated( return _lastModified === -1; } -export function getTagsFromValue(value?: CacheValue) { +export function getTagsFromValue(value?: CacheValue<"cache">) { if (!value) { return []; } diff --git a/packages/tests-e2e/tests/experimental/use-cache.test.ts b/packages/tests-e2e/tests/experimental/use-cache.test.ts new file mode 100644 index 000000000..378c496b2 --- /dev/null +++ b/packages/tests-e2e/tests/experimental/use-cache.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Composable Cache", () => { + test("cached component should work in ssr", async ({ page }) => { + await page.goto("/use-cache/ssr"); + let fullyCachedElt = page.getByTestId("fullyCached"); + let isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + + const initialFullyCachedText = await fullyCachedElt.textContent(); + const initialIsrText = await isrElt.textContent(); + + let isrText = initialIsrText; + + do { + await page.reload(); + fullyCachedElt = page.getByTestId("fullyCached"); + isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await page.waitForTimeout(1000); + } while (isrText === initialIsrText); + const fullyCachedText = await fullyCachedElt.textContent(); + expect(fullyCachedText).toEqual(initialFullyCachedText); + }); + + test("revalidateTag should work for fullyCached component", async ({ + page, + request, + }) => { + await page.goto("/use-cache/ssr"); + const fullyCachedElt = page.getByTestId("fullyCached"); + await expect(fullyCachedElt).toBeVisible(); + + const initialFullyCachedText = await fullyCachedElt.textContent(); + + const resp = await request.get("/api/revalidate"); + expect(resp.status()).toEqual(200); + expect(await resp.text()).toEqual("DONE"); + + await page.reload(); + await expect(fullyCachedElt).toBeVisible(); + const newFullyCachedText = await fullyCachedElt.textContent(); + expect(newFullyCachedText).not.toEqual(initialFullyCachedText); + }); + + test("cached component should work in isr", async ({ page }) => { + await page.goto("/use-cache/isr"); + + let fullyCachedElt = page.getByTestId("fullyCached"); + let isrElt = page.getByTestId("isr"); + + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + + let initialFullyCachedText = await fullyCachedElt.textContent(); + let initialIsrText = await isrElt.textContent(); + + // We have to force reload until ISR has triggered at least once, otherwise the test will be flakey + + let isrText = initialIsrText; + + while (isrText === initialIsrText) { + await page.reload(); + isrElt = page.getByTestId("isr"); + fullyCachedElt = page.getByTestId("fullyCached"); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await expect(fullyCachedElt).toBeVisible(); + initialFullyCachedText = await fullyCachedElt.textContent(); + await page.waitForTimeout(1000); + } + initialIsrText = isrText; + + do { + await page.reload(); + fullyCachedElt = page.getByTestId("fullyCached"); + isrElt = page.getByTestId("isr"); + await expect(fullyCachedElt).toBeVisible(); + await expect(isrElt).toBeVisible(); + isrText = await isrElt.textContent(); + await page.waitForTimeout(1000); + } while (isrText === initialIsrText); + const fullyCachedText = await fullyCachedElt.textContent(); + expect(fullyCachedText).toEqual(initialFullyCachedText); + }); +}); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 4dff3166c..e4e3977fd 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -361,7 +361,7 @@ describe("CacheHandler", () => { expect(incrementalCache.set).toHaveBeenCalledWith( "key", { type: "route", body: "{}", meta: { status: 200, headers: {} } }, - false, + "cache", ); }); @@ -382,7 +382,7 @@ describe("CacheHandler", () => { body: Buffer.from("{}").toString("base64"), meta: { status: 200, headers: { "content-type": "image/png" } }, }, - false, + "cache", ); }); @@ -402,7 +402,7 @@ describe("CacheHandler", () => { html: "", json: {}, }, - false, + "cache", ); }); @@ -423,7 +423,7 @@ describe("CacheHandler", () => { rsc: "rsc", meta: { status: 200, headers: {} }, }, - false, + "cache", ); }); @@ -444,7 +444,7 @@ describe("CacheHandler", () => { rsc: "rsc", meta: { status: 200, headers: {} }, }, - false, + "cache", ); }); @@ -474,7 +474,7 @@ describe("CacheHandler", () => { }, revalidate: 60, }, - true, + "fetch", ); }); @@ -487,7 +487,7 @@ describe("CacheHandler", () => { type: "redirect", props: {}, }, - false, + "cache", ); }); diff --git a/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts b/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts index 649c5cc13..e6d421aee 100644 --- a/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts +++ b/packages/tests-unit/tests/build/patch/patches/patchFetchCacheISR.test.ts @@ -2,6 +2,7 @@ import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; import { fetchRule, unstable_cacheRule, + useCacheRule, } from "@opennextjs/aws/build/patch/patches/patchFetchCacheISR.js"; import { describe } from "vitest"; @@ -54,6 +55,24 @@ const patchFetchCacheCodeMinifiedNext15 = ` let t=P.isOnDemandRevalidate?null:await V.get(n,{kind:l.IncrementalCacheKind.FETCH,revalidate:_,fetchUrl:y,fetchIdx:X,tags:N,softTags:C}); `; +const patchUseCacheUnminified = ` +function shouldForceRevalidate(workStore, workUnitStore) { + if (workStore.isOnDemandRevalidate || workStore.isDraftMode) { + return true; + } + if (workStore.dev && workUnitStore) { + if (workUnitStore.type === 'request') { + return workUnitStore.headers.get('cache-control') === 'no-cache'; + } + if (workUnitStore.type === 'cache') { + return workUnitStore.forceRevalidate; + } + } + return false; +}`; +const patchUseCacheMinified = ` +function D(e,t){if(e.isOnDemandRevalidate||e.isDraftMode)return!0;if(e.dev&&t){if("request"===t.type)return"no-cache"===t.headers.get("cache-control");if("cache"===t.type)return t.forceRevalidate}return!1}`; + describe("patchUnstableCacheForISR", () => { test("on unminified code", async () => { expect( @@ -124,3 +143,32 @@ describe("patchFetchCacheISR", () => { }); //TODO: Add test for Next 14.2.24 }); + +describe("patchUseCache", () => { + test("on unminified code", async () => { + expect( + patchCode(patchUseCacheUnminified, useCacheRule), + ).toMatchInlineSnapshot(` +"function shouldForceRevalidate(workStore, workUnitStore) { + if ((workStore.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation) || workStore.isDraftMode) { + return true; + } + if (workStore.dev && workUnitStore) { + if (workUnitStore.type === 'request') { + return workUnitStore.headers.get('cache-control') === 'no-cache'; + } + if (workUnitStore.type === 'cache') { + return workUnitStore.forceRevalidate; + } + } + return false; +}"`); + }); + test("on minified code", async () => { + expect( + patchCode(patchUseCacheMinified, useCacheRule), + ).toMatchInlineSnapshot(` +"function D(e,t){if((e.isOnDemandRevalidate && !globalThis.__openNextAls?.getStore()?.isISRRevalidation)||e.isDraftMode)return!0;if(e.dev&&t){if("request"===t.type)return"no-cache"===t.headers.get("cache-control");if("cache"===t.type)return t.forceRevalidate}return!1}" +`); + }); +});