Skip to content

Feat: Composable cache #820

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: feat/compute-revalidate-cache
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/experimental/next.config.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
experimental: {
ppr: "incremental",
nodeMiddleware: true,
dynamicIO: true,
},
};

6 changes: 6 additions & 0 deletions examples/experimental/src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { revalidateTag } from "next/cache";

export function GET() {
revalidateTag("fullyTagged");
return new Response("DONE");
}
17 changes: 17 additions & 0 deletions examples/experimental/src/app/use-cache/isr/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Cache</h1>
<Suspense fallback={<p>Loading...</p>}>
<FullyCachedComponent />
</Suspense>
<Suspense fallback={<p>Loading...</p>}>
<ISRComponent />
</Suspense>
</div>
);
}
13 changes: 13 additions & 0 deletions examples/experimental/src/app/use-cache/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Suspense } from "react";

export default function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
</div>
);
}
20 changes: 20 additions & 0 deletions examples/experimental/src/app/use-cache/ssr/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Cache</h1>
<p>{_headers.get("accept") ?? "No accept headers"}</p>
<Suspense fallback={<p>Loading...</p>}>
<FullyCachedComponent />
</Suspense>
<Suspense fallback={<p>Loading...</p>}>
<ISRComponent />
</Suspense>
</div>
);
}
24 changes: 24 additions & 0 deletions examples/experimental/src/components/cached.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { unstable_cacheLife, unstable_cacheTag } from "next/cache";

export async function FullyCachedComponent() {
"use cache";
unstable_cacheTag("fullyTagged");
return (
<div>
<p data-testid="fullyCached">{Date.now()}</p>
</div>
);
}

export async function ISRComponent() {
"use cache";
unstable_cacheLife({
stale: 1,
revalidate: 5,
});
return (
<div>
<p data-testid="isr">{Date.now()}</p>
</div>
);
}
16 changes: 8 additions & 8 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
@@ -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<CacheHandlerValue | null> {
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<true>(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":
115 changes: 115 additions & 0 deletions packages/open-next/src/adapters/composable-cache.ts
Original file line number Diff line number Diff line change
@@ -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 || !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<ComposableCacheEntry>) {
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;
40 changes: 37 additions & 3 deletions packages/open-next/src/build/compileCache.ts
Original file line number Diff line number Diff line change
@@ -15,19 +15,49 @@ 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 compiledComposableCacheFile = path.join(
options.buildDir,
`composable-cache.${ext}`,
);

const isAfter15 = buildHelper.compareSemver(
options.nextVersion,
">=",
"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: {
js: [
`globalThis.disableIncrementalCache = ${
config.dangerous?.disableIncrementalCache ?? false
};`,
`globalThis.disableDynamoDBCache = ${
config.dangerous?.disableTagCache ?? false
};`,
`globalThis.isNextAfter15 = ${isAfter15};`,
].join(""),
},
},
options,
);

// 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: {
@@ -44,5 +74,9 @@ export function compileCache(
},
options,
);
return outFile;

return {
cache: compiledCacheFile,
composableCache: compiledComposableCacheFile,
};
}
13 changes: 13 additions & 0 deletions packages/open-next/src/build/createServerBundle.ts
Original file line number Diff line number Diff line change
@@ -147,10 +147,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);
@@ -237,6 +243,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 +277,7 @@ async function generateBundle(
...(isAfter141
? ["experimentalIncrementalCacheHandler"]
: ["stableIncrementalCache"]),
...(isAfter152 ? [] : ["composableCache"]),
],
}),

2 changes: 1 addition & 1 deletion packages/open-next/src/core/routing/cacheInterceptor.ts
Original file line number Diff line number Diff line change
@@ -89,7 +89,7 @@ async function computeCacheControl(
async function generateResult(
event: InternalEvent,
localizedPath: string,
cachedValue: CacheValue<false>,
cachedValue: CacheValue<"cache">,
lastModified?: number,
): Promise<InternalResult> {
debug("Returning result from experimental cache");
Loading