Skip to content

Feat Automatic cdn invalidation #665

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

Merged
merged 10 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/thin-feet-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/aws": minor
---

Add an override to automatically invalidate the CDN (not enabled by default)
1 change: 1 addition & 0 deletions packages/open-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"README.md"
],
"dependencies": {
"@aws-sdk/client-cloudfront": "3.398.0",
"@aws-sdk/client-dynamodb": "^3.398.0",
"@aws-sdk/client-lambda": "^3.398.0",
"@aws-sdk/client-s3": "^3.398.0",
Expand Down
26 changes: 26 additions & 0 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,32 @@ export default class Cache {

// Update all keys with the given tag with revalidatedAt set to now
await globalThis.tagCache.writeTags(toInsert);

// We can now invalidate all paths in the CDN
// This only applies to `revalidateTag`, not to `res.revalidate()`
const uniquePaths = Array.from(
new Set(
toInsert
// We need to filter fetch cache key as they are not in the CDN
.filter((t) => t.tag.startsWith("_N_T_/"))
.map((t) => `/${t.path}`),
),
);
if (uniquePaths.length > 0) {
await globalThis.cdnInvalidationHandler.invalidatePaths(
uniquePaths.map((path) => ({
initialPath: path,
rawPath: path,
resolvedRoutes: [
{
route: path,
// TODO: ideally here we should check if it's an app router page or route
type: "app",
},
],
})),
);
}
}
} catch (e) {
error("Failed to revalidate tag", e);
Expand Down
5 changes: 5 additions & 0 deletions packages/open-next/src/core/createMainHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { debug } from "../adapters/logger";
import { generateUniqueId } from "../adapters/util";
import { openNextHandler } from "./requestHandler";
import {
resolveCdnInvalidation,
resolveConverter,
resolveIncrementalCache,
resolveProxyRequest,
Expand Down Expand Up @@ -38,6 +39,10 @@ export async function createMainHandler() {
thisFunction.override?.proxyExternalRequest,
);

globalThis.cdnInvalidationHandler = await resolveCdnInvalidation(
thisFunction.override?.cdnInvalidation,
);

globalThis.lastModified = {};

// From the config, we create the converter
Expand Down
11 changes: 9 additions & 2 deletions packages/open-next/src/core/requestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,14 @@ export async function openNextHandler(
// response is used only in the streaming case
if (responseStreaming) {
const response = createServerResponse(
internalEvent,
{
internalEvent,
isExternalRewrite: false,
isISR: false,
resolvedRoutes: [],
origin: false,
initialPath: internalEvent.rawPath,
},
headers,
responseStreaming,
);
Expand Down Expand Up @@ -162,7 +169,7 @@ export async function openNextHandler(

const req = new IncomingMessage(reqProps);
const res = createServerResponse(
preprocessedEvent,
routingResult,
overwrittenResponseHeaders,
responseStreaming,
);
Expand Down
13 changes: 13 additions & 0 deletions packages/open-next/src/core/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,16 @@ export async function resolveProxyRequest(
const m_1 = await import("../overrides/proxyExternalRequest/node.js");
return m_1.default;
}

/**
* @__PURE__
*/
export async function resolveCdnInvalidation(
cdnInvalidation: OverrideOptions["cdnInvalidation"],
) {
if (typeof cdnInvalidation === "function") {
return cdnInvalidation();
}
const m_1 = await import("../overrides/cdnInvalidation/dummy.js");
return m_1.default;
}
26 changes: 25 additions & 1 deletion packages/open-next/src/core/routing/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { MiddlewareManifest } from "types/next-types";
import type {
InternalEvent,
InternalResult,
RoutingResult,
StreamCreator,
} from "types/open-next.js";

Expand Down Expand Up @@ -403,10 +404,11 @@ export function fixISRHeaders(headers: OutgoingHttpHeaders) {
* @__PURE__
*/
export function createServerResponse(
internalEvent: InternalEvent,
routingResult: RoutingResult,
headers: Record<string, string | string[] | undefined>,
responseStream?: StreamCreator,
) {
const internalEvent = routingResult.internalEvent;
return new OpenNextNodeResponse(
(_headers) => {
fixCacheHeaderForHtmlPages(internalEvent, _headers);
Expand All @@ -420,8 +422,30 @@ export function createServerResponse(
internalEvent.rawPath,
_headers,
);
await invalidateCDNOnRequest(routingResult, _headers);
},
responseStream,
headers,
);
}

// This function is used only for `res.revalidate()`
export async function invalidateCDNOnRequest(
params: RoutingResult,
headers: OutgoingHttpHeaders,
) {
const { internalEvent, initialPath, resolvedRoutes } = params;
const isIsrRevalidation = internalEvent.headers["x-isr"] === "1";
if (
!isIsrRevalidation &&
headers[CommonHeaders.NEXT_CACHE] === "REVALIDATED"
) {
await globalThis.cdnInvalidationHandler.invalidatePaths([
{
initialPath,
rawPath: internalEvent.rawPath,
resolvedRoutes,
},
]);
}
}
37 changes: 37 additions & 0 deletions packages/open-next/src/overrides/cdnInvalidation/cloudfront.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
CloudFrontClient,
CreateInvalidationCommand,
} from "@aws-sdk/client-cloudfront";
import type { CDNInvalidationHandler } from "types/overrides";

const cloudfront = new CloudFrontClient({});
export default {
name: "cloudfront",
invalidatePaths: async (paths) => {
const constructedPaths = paths.flatMap(
({ initialPath, resolvedRoutes }) => {
const isAppRouter = resolvedRoutes.some(
(route) => route.type === "app",
);
// revalidateTag doesn't have any leading slash, remove it just to be sure
const path = initialPath.replace(/^\//, "");
return isAppRouter
? [`/${path}`, `/${path}?_rsc=*`]
: [
`/${path}`,
`/_next/data/${process.env.NEXT_BUILD_ID}${path === "/" ? "/index" : `/${path}`}.json*`,
];
},
);
await cloudfront.send(
new CreateInvalidationCommand({
DistributionId: process.env.CLOUDFRONT_DISTRIBUTION_ID!,
InvalidationBatch: {
// Do we need to limit the number of paths? Or batch them into multiple commands?
Paths: { Quantity: constructedPaths.length, Items: constructedPaths },
CallerReference: `${Date.now()}`,
},
}),
);
},
} satisfies CDNInvalidationHandler;
8 changes: 8 additions & 0 deletions packages/open-next/src/overrides/cdnInvalidation/dummy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { CDNInvalidationHandler } from "types/overrides";

export default {
name: "dummy",
invalidatePaths: (_) => {
return Promise.resolve();
},
} satisfies CDNInvalidationHandler;
3 changes: 3 additions & 0 deletions packages/open-next/src/plugins/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface IPluginSettings {
| IncludedOriginResolver;
warmer?: LazyLoadedOverride<Warmer> | IncludedWarmer;
proxyExternalRequest?: OverrideOptions["proxyExternalRequest"];
cdnInvalidation?: OverrideOptions["cdnInvalidation"];
};
fnName?: string;
}
Expand All @@ -52,6 +53,7 @@ const nameToFolder = {
originResolver: "originResolver",
warmer: "warmer",
proxyExternalRequest: "proxyExternalRequest",
cdnInvalidation: "cdnInvalidation",
};

const defaultOverrides = {
Expand All @@ -64,6 +66,7 @@ const defaultOverrides = {
originResolver: "pattern-env",
warmer: "aws-lambda",
proxyExternalRequest: "node",
cdnInvalidation: "dummy",
};

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/open-next/src/types/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AsyncLocalStorage } from "node:async_hooks";
import type { OutgoingHttpHeaders } from "node:http";

import type {
CDNInvalidationHandler,
IncrementalCache,
ProxyExternalRequest,
Queue,
Expand Down Expand Up @@ -206,4 +207,11 @@ declare global {
* Defined in `createMainHandler`.
*/
var proxyExternalRequest: ProxyExternalRequest;

/**
* The function that will be called when the CDN needs invalidating (either from `revalidateTag` or from `res.revalidate`)
* Available in main functions
* Defined in `createMainHandler`
*/
var cdnInvalidationHandler: CDNInvalidationHandler;
}
11 changes: 11 additions & 0 deletions packages/open-next/src/types/open-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReadableStream } from "node:stream/web";
import type { Writable } from "node:stream";
import type { WarmerEvent, WarmerResponse } from "../adapters/warmer-function";
import type {
CDNInvalidationHandler,
Converter,
ImageLoader,
IncrementalCache,
Expand Down Expand Up @@ -152,6 +153,8 @@ export type IncludedWarmer = "aws-lambda" | "dummy";

export type IncludedProxyExternalRequest = "node" | "fetch" | "dummy";

export type IncludedCDNInvalidationHandler = "cloudfront" | "dummy";

export interface DefaultOverrideOptions<
E extends BaseEventOrResult = InternalEvent,
R extends BaseEventOrResult = InternalResult,
Expand Down Expand Up @@ -203,6 +206,14 @@ export interface OverrideOptions extends DefaultOverrideOptions {
proxyExternalRequest?:
| IncludedProxyExternalRequest
| LazyLoadedOverride<ProxyExternalRequest>;

/**
* Add possibility to override the default cdn invalidation for On Demand Revalidation
* @default "dummy"
*/
cdnInvalidation?:
| IncludedCDNInvalidationHandler
| LazyLoadedOverride<CDNInvalidationHandler>;
}

export interface InstallOptions {
Expand Down
11 changes: 11 additions & 0 deletions packages/open-next/src/types/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
InternalEvent,
InternalResult,
Origin,
ResolvedRoute,
StreamCreator,
} from "./open-next";

Expand Down Expand Up @@ -139,3 +140,13 @@ export type OriginResolver = BaseOverride & {
export type ProxyExternalRequest = BaseOverride & {
proxy: (event: InternalEvent) => Promise<InternalResult>;
};

type CDNPath = {
initialPath: string;
rawPath: string;
resolvedRoutes: ResolvedRoute[];
};

export type CDNInvalidationHandler = BaseOverride & {
invalidatePaths: (paths: CDNPath[]) => Promise<void>;
};
75 changes: 75 additions & 0 deletions packages/tests-unit/tests/adapters/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { vi } from "vitest";

declare global {
var disableIncrementalCache: boolean;
var disableDynamoDBCache: boolean;
var isNextAfter15: boolean;
}

Expand Down Expand Up @@ -39,6 +40,12 @@ describe("CacheHandler", () => {
};
globalThis.tagCache = tagCache;

const invalidateCdnHandler = {
name: "mock",
invalidatePaths: vi.fn(),
};
globalThis.cdnInvalidationHandler = invalidateCdnHandler;

globalThis.__openNextAls = {
getStore: vi.fn().mockReturnValue({
requestId: "123",
Expand Down Expand Up @@ -470,4 +477,72 @@ describe("CacheHandler", () => {
).not.toThrow();
});
});

describe("revalidateTag", () => {
beforeEach(() => {
globalThis.disableDynamoDBCache = false;
globalThis.disableIncrementalCache = false;
});
it("Should do nothing if disableIncrementalCache is true", async () => {
globalThis.disableIncrementalCache = true;

await cache.revalidateTag("tag");

expect(tagCache.writeTags).not.toHaveBeenCalled();
});

it("Should do nothing if disableTagCache is true", async () => {
globalThis.disableDynamoDBCache = true;

await cache.revalidateTag("tag");

expect(tagCache.writeTags).not.toHaveBeenCalled();
});

it("Should call tagCache.writeTags", async () => {
globalThis.tagCache.getByTag.mockResolvedValueOnce(["/path"]);
await cache.revalidateTag("tag");

expect(globalThis.tagCache.getByTag).toHaveBeenCalledWith("tag");

expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
expect(tagCache.writeTags).toHaveBeenCalledWith([
{
path: "/path",
tag: "tag",
},
]);
});

it("Should call invalidateCdnHandler.invalidatePaths", async () => {
globalThis.tagCache.getByTag.mockResolvedValueOnce(["/path"]);
globalThis.tagCache.getByPath.mockResolvedValueOnce([]);
await cache.revalidateTag("_N_T_/path");

expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
expect(tagCache.writeTags).toHaveBeenCalledWith([
{
path: "/path",
tag: "_N_T_/path",
},
]);

expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalled();
});

it("Should not call invalidateCdnHandler.invalidatePaths for fetch cache key ", async () => {
globalThis.tagCache.getByTag.mockResolvedValueOnce(["123456"]);
await cache.revalidateTag("tag");

expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
expect(tagCache.writeTags).toHaveBeenCalledWith([
{
path: "123456",
tag: "tag",
},
]);

expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled();
});
});
});
Loading
Loading