From 0558bf6d0d5a53a32628cd251d572c3578cfa7a1 Mon Sep 17 00:00:00 2001 From: conico974 Date: Thu, 22 Aug 2024 11:26:48 +0200 Subject: [PATCH] Feat external cache (#480) * very early implementation of an external cache * fix some bug * make cache interception optional * fix for external middleware * use ReadableStream for the body * changeset * add support for basePath --- .changeset/perfect-wasps-walk.md | 5 + examples/app-pages-router/open-next.config.ts | 3 + packages/open-next/src/adapters/middleware.ts | 22 ++ packages/open-next/src/build.ts | 1 + .../src/build/edge/createEdgeBundle.ts | 28 ++- .../open-next/src/cache/incremental/types.ts | 4 +- .../open-next/src/core/createMainHandler.ts | 1 + .../src/core/routing/cacheInterceptor.ts | 210 ++++++++++++++++++ packages/open-next/src/core/routing/util.ts | 2 +- packages/open-next/src/core/routingHandler.ts | 43 +++- packages/open-next/src/plugins/edge.ts | 2 + packages/open-next/src/types/next-types.ts | 10 +- packages/open-next/src/types/open-next.ts | 14 ++ .../tests/appRouter/revalidateTag.test.ts | 9 +- 14 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 .changeset/perfect-wasps-walk.md create mode 100644 packages/open-next/src/core/routing/cacheInterceptor.ts diff --git a/.changeset/perfect-wasps-walk.md b/.changeset/perfect-wasps-walk.md new file mode 100644 index 00000000..37799cb2 --- /dev/null +++ b/.changeset/perfect-wasps-walk.md @@ -0,0 +1,5 @@ +--- +"open-next": minor +--- + +Add an optional external cache diff --git a/examples/app-pages-router/open-next.config.ts b/examples/app-pages-router/open-next.config.ts index 2a9b827b..086d696f 100644 --- a/examples/app-pages-router/open-next.config.ts +++ b/examples/app-pages-router/open-next.config.ts @@ -6,6 +6,9 @@ const config = { patterns: ["/api/*"], }, }, + dangerous: { + enableCacheInterception: true, + }, buildCommand: "npx turbo build", }; diff --git a/packages/open-next/src/adapters/middleware.ts b/packages/open-next/src/adapters/middleware.ts index 192778b3..d258f161 100644 --- a/packages/open-next/src/adapters/middleware.ts +++ b/packages/open-next/src/adapters/middleware.ts @@ -2,6 +2,11 @@ import { InternalEvent, Origin, OriginResolver } from "types/open-next"; import { debug, error } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; +import { + resolveIncrementalCache, + resolveQueue, + resolveTagCache, +} from "../core/resolve"; import routingHandler from "../core/routingHandler"; const resolveOriginResolver = () => { @@ -55,8 +60,25 @@ const resolveOriginResolver = () => { } }; +globalThis.internalFetch = fetch; + const defaultHandler = async (internalEvent: InternalEvent) => { const originResolver = await resolveOriginResolver(); + + //#override includeCacheInMiddleware + globalThis.tagCache = await resolveTagCache( + globalThis.openNextConfig.middleware?.override?.tagCache, + ); + + globalThis.queue = await resolveQueue( + globalThis.openNextConfig.middleware?.override?.queue, + ); + + globalThis.incrementalCache = await resolveIncrementalCache( + globalThis.openNextConfig.middleware?.override?.incrementalCache, + ); + //#endOverride + const result = await routingHandler(internalEvent); if ("internalEvent" in result) { debug("Middleware intercepted event", internalEvent); diff --git a/packages/open-next/src/build.ts b/packages/open-next/src/build.ts index e40c1c48..040ed1e9 100755 --- a/packages/open-next/src/build.ts +++ b/packages/open-next/src/build.ts @@ -752,6 +752,7 @@ async function createMiddleware() { ...commonMiddlewareOptions, overrides: config.middleware?.override, defaultConverter: "aws-cloudfront", + includeCache: config.dangerous?.enableCacheInterception, }); } else { await buildEdgeBundle({ diff --git a/packages/open-next/src/build/edge/createEdgeBundle.ts b/packages/open-next/src/build/edge/createEdgeBundle.ts index fdde4dcc..3858d024 100644 --- a/packages/open-next/src/build/edge/createEdgeBundle.ts +++ b/packages/open-next/src/build/edge/createEdgeBundle.ts @@ -5,14 +5,15 @@ import fs from "fs"; import path from "path"; import { MiddlewareInfo, MiddlewareManifest } from "types/next-types"; import { - DefaultOverrideOptions, IncludedConverter, + OverrideOptions, RouteTemplate, SplittedFunctionOptions, } from "types/open-next"; import logger from "../../logger.js"; import { openNextEdgePlugins } from "../../plugins/edge.js"; +import { openNextReplacementPlugin } from "../../plugins/replacement.js"; import { openNextResolvePlugin } from "../../plugins/resolve.js"; import { BuildOptions, copyOpenNextConfig, esbuildAsync } from "../helper.js"; @@ -24,9 +25,10 @@ interface BuildEdgeBundleOptions { entrypoint: string; outfile: string; options: BuildOptions; - overrides?: DefaultOverrideOptions; + overrides?: OverrideOptions; defaultConverter?: IncludedConverter; additionalInject?: string; + includeCache?: boolean; } export async function buildEdgeBundle({ @@ -38,6 +40,7 @@ export async function buildEdgeBundle({ defaultConverter, overrides, additionalInject, + includeCache, }: BuildEdgeBundleOptions) { await esbuildAsync( { @@ -59,8 +62,29 @@ export async function buildEdgeBundle({ typeof overrides?.converter === "string" ? overrides.converter : defaultConverter, + ...(includeCache + ? { + tagCache: + typeof overrides?.tagCache === "string" + ? overrides.tagCache + : "dynamodb-lite", + incrementalCache: + typeof overrides?.incrementalCache === "string" + ? overrides.incrementalCache + : "s3-lite", + queue: + typeof overrides?.queue === "string" + ? overrides.queue + : "sqs-lite", + } + : {}), }, }), + openNextReplacementPlugin({ + name: "externalMiddlewareOverrides", + target: /adapters(\/|\\)middleware\.js/g, + deletes: includeCache ? [] : ["includeCacheInMiddleware"], + }), openNextEdgePlugins({ middlewareInfo, nextDir: path.join(appBuildOutputPath, ".next"), diff --git a/packages/open-next/src/cache/incremental/types.ts b/packages/open-next/src/cache/incremental/types.ts index 60217cf5..81030eed 100644 --- a/packages/open-next/src/cache/incremental/types.ts +++ b/packages/open-next/src/cache/incremental/types.ts @@ -31,9 +31,9 @@ export type WithLastModified = { value?: T; }; -export type CacheValue = IsFetch extends true +export type CacheValue = (IsFetch extends true ? S3FetchCache - : S3CachedFile; + : S3CachedFile) & { revalidate?: number | false }; export type IncrementalCache = { get( diff --git a/packages/open-next/src/core/createMainHandler.ts b/packages/open-next/src/core/createMainHandler.ts index 67c7dd20..c7d5dbec 100644 --- a/packages/open-next/src/core/createMainHandler.ts +++ b/packages/open-next/src/core/createMainHandler.ts @@ -38,6 +38,7 @@ export async function createMainHandler() { : config.default; globalThis.serverId = generateUniqueId(); + globalThis.openNextConfig = config; // Default queue globalThis.queue = await resolveQueue(thisFunction.override?.queue); diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts new file mode 100644 index 00000000..a3f83f0c --- /dev/null +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -0,0 +1,210 @@ +import { createHash } from "node:crypto"; + +import { NextConfig, PrerenderManifest } from "config/index"; +import { InternalEvent, InternalResult } from "types/open-next"; +import { emptyReadableStream, toReadableStream } from "utils/stream"; + +import { debug } from "../../adapters/logger"; +import { CacheValue } from "../../cache/incremental/types"; +import { localizePath } from "./i18n"; +import { generateMessageGroupId } from "./util"; + +const CACHE_ONE_YEAR = 60 * 60 * 24 * 365; +const CACHE_ONE_MONTH = 60 * 60 * 24 * 30; + +async function computeCacheControl( + path: string, + body: string, + host: string, + revalidate?: number | false, + lastModified?: number, +) { + let finalRevalidate = CACHE_ONE_YEAR; + + const existingRoute = Object.entries(PrerenderManifest.routes).find( + (p) => p[0] === path, + )?.[1]; + if (revalidate === undefined && existingRoute) { + finalRevalidate = + existingRoute.initialRevalidateSeconds === false + ? CACHE_ONE_YEAR + : existingRoute.initialRevalidateSeconds; + // eslint-disable-next-line sonarjs/elseif-without-else + } else if (revalidate !== undefined) { + finalRevalidate = revalidate === false ? CACHE_ONE_YEAR : revalidate; + } + // calculate age + const age = Math.round((Date.now() - (lastModified ?? 0)) / 1000); + const hash = (str: string) => createHash("md5").update(str).digest("hex"); + const etag = hash(body); + if (revalidate === 0) { + // This one should never happen + return { + "cache-control": + "private, no-cache, no-store, max-age=0, must-revalidate", + "x-opennext-cache": "ERROR", + etag, + }; + } else if (finalRevalidate !== CACHE_ONE_YEAR) { + const sMaxAge = Math.max(finalRevalidate - age, 1); + debug("sMaxAge", { + finalRevalidate, + age, + lastModified, + revalidate, + }); + const isStale = sMaxAge === 1; + if (isStale) { + let url = NextConfig.trailingSlash ? `${path}/` : path; + if (NextConfig.basePath) { + // We need to add the basePath to the url + url = `${NextConfig.basePath}${url}`; + } + await globalThis.queue.send({ + MessageBody: { host, url }, + MessageDeduplicationId: hash(`${path}-${lastModified}-${etag}`), + MessageGroupId: generateMessageGroupId(path), + }); + } + return { + "cache-control": `s-maxage=${sMaxAge}, stale-while-revalidate=${CACHE_ONE_MONTH}`, + "x-opennext-cache": isStale ? "STALE" : "HIT", + etag, + }; + } else { + return { + "cache-control": `s-maxage=${CACHE_ONE_YEAR}, stale-while-revalidate=${CACHE_ONE_MONTH}`, + "x-opennext-cache": "HIT", + etag, + }; + } +} + +async function generateResult( + event: InternalEvent, + localizedPath: string, + cachedValue: CacheValue, + lastModified?: number, +): Promise { + debug("Returning result from experimental cache"); + let body = ""; + let type = "application/octet-stream"; + let isDataRequest = false; + switch (cachedValue.type) { + case "app": + isDataRequest = Boolean(event.headers["rsc"]); + body = isDataRequest ? cachedValue.rsc : cachedValue.html; + type = isDataRequest ? "text/x-component" : "text/html; charset=utf-8"; + break; + case "page": + isDataRequest = Boolean(event.query["__nextDataReq"]); + body = isDataRequest + ? JSON.stringify(cachedValue.json) + : cachedValue.html; + type = isDataRequest ? "application/json" : "text/html; charset=utf-8"; + break; + } + const cacheControl = await computeCacheControl( + localizedPath, + body, + event.headers["host"], + cachedValue.revalidate, + lastModified, + ); + return { + type: "core", + statusCode: 200, + body: toReadableStream(body, false), + isBase64Encoded: false, + headers: { + ...cacheControl, + "content-type": type, + ...cachedValue.meta?.headers, + }, + }; +} + +export async function cacheInterceptor( + event: InternalEvent, +): Promise { + if ( + Boolean(event.headers["next-action"]) || + Boolean(event.headers["x-prerender-revalidate"]) + ) + return event; + // We localize the path in case i18n is enabled + let localizedPath = localizePath(event); + // If using basePath we need to remove it from the path + if (NextConfig.basePath) { + localizedPath = localizedPath.replace(NextConfig.basePath, ""); + } + // We also need to remove trailing slash + localizedPath = localizedPath.replace(/\/$/, ""); + // If empty path, it means we want index + if (localizedPath === "") { + localizedPath = "index"; + } + + debug("Checking cache for", localizedPath, PrerenderManifest); + + const isISR = + Object.keys(PrerenderManifest.routes).includes(localizedPath) || + Object.values(PrerenderManifest.dynamicRoutes).some((dr) => + new RegExp(dr.routeRegex).test(localizedPath), + ); + debug("isISR", isISR); + if (isISR) { + try { + const cachedData = await globalThis.incrementalCache.get(localizedPath); + debug("cached data in interceptor", cachedData); + if (cachedData.value?.type === "app") { + // We need to check the tag cache now + const _lastModified = await globalThis.tagCache.getLastModified( + localizedPath, + cachedData.lastModified, + ); + if (_lastModified === -1) { + // If some tags are stale we need to force revalidation + return event; + } + } + const host = event.headers["host"]; + switch (cachedData.value?.type) { + case "app": + case "page": + return generateResult( + event, + localizedPath, + cachedData.value, + cachedData.lastModified, + ); + case "redirect": + const cacheControl = await computeCacheControl( + localizedPath, + "", + host, + cachedData.value.revalidate, + cachedData.lastModified, + ); + return { + type: "core", + statusCode: cachedData.value.meta?.status ?? 307, + body: emptyReadableStream(), + headers: { + ...((cachedData.value.meta?.headers as Record) ?? + {}), + ...cacheControl, + }, + isBase64Encoded: false, + }; + default: + return event; + } + } catch (e) { + debug("Error while fetching cache", e); + // In case of error we fallback to the server + return event; + } + } + return event; +} diff --git a/packages/open-next/src/core/routing/util.ts b/packages/open-next/src/core/routing/util.ts index 3c2ac43a..bae3d413 100644 --- a/packages/open-next/src/core/routing/util.ts +++ b/packages/open-next/src/core/routing/util.ts @@ -393,7 +393,7 @@ export async function revalidateIfRequired( // We can't just use a random string because we need to ensure that the same rawPath // will always have the same messageGroupId. // https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript#answer-47593316 -function generateMessageGroupId(rawPath: string) { +export function generateMessageGroupId(rawPath: string) { let a = cyrb128(rawPath); // We use mulberry32 to generate a random int between 0 and MAX_REVALIDATE_CONCURRENCY var t = (a += 0x6d2b79f5); diff --git a/packages/open-next/src/core/routingHandler.ts b/packages/open-next/src/core/routingHandler.ts index 8b80320c..54936d66 100644 --- a/packages/open-next/src/core/routingHandler.ts +++ b/packages/open-next/src/core/routingHandler.ts @@ -7,6 +7,7 @@ import { import { InternalEvent, InternalResult, Origin } from "types/open-next"; import { debug } from "../adapters/logger"; +import { cacheInterceptor } from "./routing/cacheInterceptor"; import { addNextConfigHeaders, fixDataPage, @@ -51,6 +52,19 @@ const dynamicRegexp = RoutesManifest.routes.dynamic.map( ), ); +function applyMiddlewareHeaders( + eventHeaders: Record, + middlewareHeaders: Record, + setPrefix = true, +) { + Object.entries(middlewareHeaders).forEach(([key, value]) => { + if (value) { + eventHeaders[`${setPrefix ? "x-middleware-response-" : ""}${key}`] = + Array.isArray(value) ? value.join(",") : value; + } + }); +} + export default async function routingHandler( event: InternalEvent, ): Promise { @@ -165,18 +179,29 @@ export default async function routingHandler( }; } + if ( + globalThis.openNextConfig.dangerous?.enableCacheInterception && + !("statusCode" in internalEvent) + ) { + debug("Cache interception enabled"); + internalEvent = await cacheInterceptor(internalEvent); + if ("statusCode" in internalEvent) { + applyMiddlewareHeaders( + internalEvent.headers, + { + ...middlewareResponseHeaders, + ...nextHeaders, + }, + false, + ); + return internalEvent; + } + } + // We apply the headers from the middleware response last - Object.entries({ + applyMiddlewareHeaders(internalEvent.headers, { ...middlewareResponseHeaders, ...nextHeaders, - }).forEach(([key, value]) => { - if (value) { - internalEvent.headers[`x-middleware-response-${key}`] = Array.isArray( - value, - ) - ? value.join(",") - : value; - } }); return { diff --git a/packages/open-next/src/plugins/edge.ts b/packages/open-next/src/plugins/edge.ts index 897de058..2422421c 100644 --- a/packages/open-next/src/plugins/edge.ts +++ b/packages/open-next/src/plugins/edge.ts @@ -194,6 +194,8 @@ ${contents} export const PrerenderManifest = ${JSON.stringify(PrerenderManifest)}; export const AppPathsManifestKeys = ${JSON.stringify(AppPathsManifestKeys)}; export const MiddlewareManifest = ${JSON.stringify(MiddlewareManifest)}; + + process.env.NEXT_BUILD_ID = BuildId; `; return { diff --git a/packages/open-next/src/types/next-types.ts b/packages/open-next/src/types/next-types.ts index 0d581272..8a5aa5c7 100644 --- a/packages/open-next/src/types/next-types.ts +++ b/packages/open-next/src/types/next-types.ts @@ -67,7 +67,7 @@ export interface i18nConfig { } export interface NextConfig { basePath?: string; - trailingSlash?: string; + trailingSlash?: boolean; skipTrailingSlashRedirect?: boolean; i18n?: i18nConfig; experimental: { @@ -150,7 +150,13 @@ export interface MiddlewareManifest { } export interface PrerenderManifest { - routes: Record; + routes: Record< + string, + { + // TODO: add the rest when needed for PPR + initialRevalidateSeconds: number | false; + } + >; dynamicRoutes: { [route: string]: { routeRegex: string; diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index eb118725..d0912027 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -42,6 +42,13 @@ export interface DangerousOptions { * @default false */ disableIncrementalCache?: boolean; + /** + * Enable the cache interception. + * Every request will go through the cache interceptor, if it is found in the cache, it will be returned without going through NextServer. + * Not every feature is covered by the cache interceptor and it should fallback to the NextServer if the cache is not found. + * @default false + */ + enableCacheInterception?: boolean; } export type BaseOverride = { @@ -254,6 +261,13 @@ export interface OpenNextConfig { //We force the middleware to be a function external: true; + /** + * The override options for the middleware. + * By default the lite override are used (.i.e. s3-lite, dynamodb-lite, sqs-lite) + * @default undefined + */ + override?: OverrideOptions; + /** * Origin resolver is used to resolve the origin for internal rewrite. * By default, it uses the pattern-env origin resolver. diff --git a/packages/tests-e2e/tests/appRouter/revalidateTag.test.ts b/packages/tests-e2e/tests/appRouter/revalidateTag.test.ts index c7a00c67..124c61b8 100644 --- a/packages/tests-e2e/tests/appRouter/revalidateTag.test.ts +++ b/packages/tests-e2e/tests/appRouter/revalidateTag.test.ts @@ -21,7 +21,9 @@ test("Revalidate tag", async ({ page, request }) => { let newTime; let response = await responsePromise; - const nextCacheHeader = response.headers()["x-nextjs-cache"]; + const headers = response.headers(); + const nextCacheHeader = + headers["x-nextjs-cache"] ?? headers["x-opennext-cache"]; expect(nextCacheHeader).toMatch(/^(HIT|STALE)$/); // Send revalidate tag request @@ -62,7 +64,10 @@ test("Revalidate tag", async ({ page, request }) => { await page.goto("/revalidate-tag/nested"); response = await responsePromise; - expect(response.headers()["x-nextjs-cache"]).toEqual("HIT"); + const headersNested = response.headers(); + const nextCacheHeaderNested = + headersNested["x-nextjs-cache"] ?? headersNested["x-opennext-cache"]; + expect(nextCacheHeaderNested).toEqual("HIT"); }); test("Revalidate path", async ({ page, request }) => {