Skip to content

Commit

Permalink
Feat external cache (#480)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
conico974 authored Aug 22, 2024
1 parent a7a888d commit 0558bf6
Show file tree
Hide file tree
Showing 14 changed files with 336 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changeset/perfect-wasps-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"open-next": minor
---

Add an optional external cache
3 changes: 3 additions & 0 deletions examples/app-pages-router/open-next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const config = {
patterns: ["/api/*"],
},
},
dangerous: {
enableCacheInterception: true,
},
buildCommand: "npx turbo build",
};

Expand Down
22 changes: 22 additions & 0 deletions packages/open-next/src/adapters/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/open-next/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,7 @@ async function createMiddleware() {
...commonMiddlewareOptions,
overrides: config.middleware?.override,
defaultConverter: "aws-cloudfront",
includeCache: config.dangerous?.enableCacheInterception,
});
} else {
await buildEdgeBundle({
Expand Down
28 changes: 26 additions & 2 deletions packages/open-next/src/build/edge/createEdgeBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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({
Expand All @@ -38,6 +40,7 @@ export async function buildEdgeBundle({
defaultConverter,
overrides,
additionalInject,
includeCache,
}: BuildEdgeBundleOptions) {
await esbuildAsync(
{
Expand All @@ -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"),
Expand Down
4 changes: 2 additions & 2 deletions packages/open-next/src/cache/incremental/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ export type WithLastModified<T> = {
value?: T;
};

export type CacheValue<IsFetch extends boolean> = IsFetch extends true
export type CacheValue<IsFetch extends boolean> = (IsFetch extends true
? S3FetchCache
: S3CachedFile;
: S3CachedFile) & { revalidate?: number | false };

export type IncrementalCache = {
get<IsFetch extends boolean = false>(
Expand Down
1 change: 1 addition & 0 deletions packages/open-next/src/core/createMainHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
210 changes: 210 additions & 0 deletions packages/open-next/src/core/routing/cacheInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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<false>,
lastModified?: number,
): Promise<InternalResult> {
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<InternalEvent | InternalResult> {
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<string, string>) ??
{}),
...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;
}
2 changes: 1 addition & 1 deletion packages/open-next/src/core/routing/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 0558bf6

Please sign in to comment.