Skip to content

Commit ae7fb9c

Browse files
authored
Feat Automatic cdn invalidation (#665)
* basic implementation * install and biome fix * cloudfront implementation * review fix * fix & comment * update to use initialPath when needed * fix build * fix linting * changeset * review fix
1 parent d1cea56 commit ae7fb9c

File tree

16 files changed

+1102
-4
lines changed

16 files changed

+1102
-4
lines changed

.changeset/thin-feet-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/aws": minor
3+
---
4+
5+
Add an override to automatically invalidate the CDN (not enabled by default)

packages/open-next/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"README.md"
3535
],
3636
"dependencies": {
37+
"@aws-sdk/client-cloudfront": "3.398.0",
3738
"@aws-sdk/client-dynamodb": "^3.398.0",
3839
"@aws-sdk/client-lambda": "^3.398.0",
3940
"@aws-sdk/client-s3": "^3.398.0",

packages/open-next/src/adapters/cache.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,32 @@ export default class Cache {
433433

434434
// Update all keys with the given tag with revalidatedAt set to now
435435
await globalThis.tagCache.writeTags(toInsert);
436+
437+
// We can now invalidate all paths in the CDN
438+
// This only applies to `revalidateTag`, not to `res.revalidate()`
439+
const uniquePaths = Array.from(
440+
new Set(
441+
toInsert
442+
// We need to filter fetch cache key as they are not in the CDN
443+
.filter((t) => t.tag.startsWith("_N_T_/"))
444+
.map((t) => `/${t.path}`),
445+
),
446+
);
447+
if (uniquePaths.length > 0) {
448+
await globalThis.cdnInvalidationHandler.invalidatePaths(
449+
uniquePaths.map((path) => ({
450+
initialPath: path,
451+
rawPath: path,
452+
resolvedRoutes: [
453+
{
454+
route: path,
455+
// TODO: ideally here we should check if it's an app router page or route
456+
type: "app",
457+
},
458+
],
459+
})),
460+
);
461+
}
436462
}
437463
} catch (e) {
438464
error("Failed to revalidate tag", e);

packages/open-next/src/core/createMainHandler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { debug } from "../adapters/logger";
44
import { generateUniqueId } from "../adapters/util";
55
import { openNextHandler } from "./requestHandler";
66
import {
7+
resolveCdnInvalidation,
78
resolveConverter,
89
resolveIncrementalCache,
910
resolveProxyRequest,
@@ -38,6 +39,10 @@ export async function createMainHandler() {
3839
thisFunction.override?.proxyExternalRequest,
3940
);
4041

42+
globalThis.cdnInvalidationHandler = await resolveCdnInvalidation(
43+
thisFunction.override?.cdnInvalidation,
44+
);
45+
4146
globalThis.lastModified = {};
4247

4348
// From the config, we create the converter

packages/open-next/src/core/requestHandler.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,14 @@ export async function openNextHandler(
118118
// response is used only in the streaming case
119119
if (responseStreaming) {
120120
const response = createServerResponse(
121-
internalEvent,
121+
{
122+
internalEvent,
123+
isExternalRewrite: false,
124+
isISR: false,
125+
resolvedRoutes: [],
126+
origin: false,
127+
initialPath: internalEvent.rawPath,
128+
},
122129
headers,
123130
responseStreaming,
124131
);
@@ -162,7 +169,7 @@ export async function openNextHandler(
162169

163170
const req = new IncomingMessage(reqProps);
164171
const res = createServerResponse(
165-
preprocessedEvent,
172+
routingResult,
166173
overwrittenResponseHeaders,
167174
responseStreaming,
168175
);

packages/open-next/src/core/resolve.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,16 @@ export async function resolveProxyRequest(
141141
const m_1 = await import("../overrides/proxyExternalRequest/node.js");
142142
return m_1.default;
143143
}
144+
145+
/**
146+
* @__PURE__
147+
*/
148+
export async function resolveCdnInvalidation(
149+
cdnInvalidation: OverrideOptions["cdnInvalidation"],
150+
) {
151+
if (typeof cdnInvalidation === "function") {
152+
return cdnInvalidation();
153+
}
154+
const m_1 = await import("../overrides/cdnInvalidation/dummy.js");
155+
return m_1.default;
156+
}

packages/open-next/src/core/routing/util.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { MiddlewareManifest } from "types/next-types";
1010
import type {
1111
InternalEvent,
1212
InternalResult,
13+
RoutingResult,
1314
StreamCreator,
1415
} from "types/open-next.js";
1516

@@ -403,10 +404,11 @@ export function fixISRHeaders(headers: OutgoingHttpHeaders) {
403404
* @__PURE__
404405
*/
405406
export function createServerResponse(
406-
internalEvent: InternalEvent,
407+
routingResult: RoutingResult,
407408
headers: Record<string, string | string[] | undefined>,
408409
responseStream?: StreamCreator,
409410
) {
411+
const internalEvent = routingResult.internalEvent;
410412
return new OpenNextNodeResponse(
411413
(_headers) => {
412414
fixCacheHeaderForHtmlPages(internalEvent, _headers);
@@ -420,8 +422,30 @@ export function createServerResponse(
420422
internalEvent.rawPath,
421423
_headers,
422424
);
425+
await invalidateCDNOnRequest(routingResult, _headers);
423426
},
424427
responseStream,
425428
headers,
426429
);
427430
}
431+
432+
// This function is used only for `res.revalidate()`
433+
export async function invalidateCDNOnRequest(
434+
params: RoutingResult,
435+
headers: OutgoingHttpHeaders,
436+
) {
437+
const { internalEvent, initialPath, resolvedRoutes } = params;
438+
const isIsrRevalidation = internalEvent.headers["x-isr"] === "1";
439+
if (
440+
!isIsrRevalidation &&
441+
headers[CommonHeaders.NEXT_CACHE] === "REVALIDATED"
442+
) {
443+
await globalThis.cdnInvalidationHandler.invalidatePaths([
444+
{
445+
initialPath,
446+
rawPath: internalEvent.rawPath,
447+
resolvedRoutes,
448+
},
449+
]);
450+
}
451+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
CloudFrontClient,
3+
CreateInvalidationCommand,
4+
} from "@aws-sdk/client-cloudfront";
5+
import type { CDNInvalidationHandler } from "types/overrides";
6+
7+
const cloudfront = new CloudFrontClient({});
8+
export default {
9+
name: "cloudfront",
10+
invalidatePaths: async (paths) => {
11+
const constructedPaths = paths.flatMap(
12+
({ initialPath, resolvedRoutes }) => {
13+
const isAppRouter = resolvedRoutes.some(
14+
(route) => route.type === "app",
15+
);
16+
// revalidateTag doesn't have any leading slash, remove it just to be sure
17+
const path = initialPath.replace(/^\//, "");
18+
return isAppRouter
19+
? [`/${path}`, `/${path}?_rsc=*`]
20+
: [
21+
`/${path}`,
22+
`/_next/data/${process.env.NEXT_BUILD_ID}${path === "/" ? "/index" : `/${path}`}.json*`,
23+
];
24+
},
25+
);
26+
await cloudfront.send(
27+
new CreateInvalidationCommand({
28+
DistributionId: process.env.CLOUDFRONT_DISTRIBUTION_ID!,
29+
InvalidationBatch: {
30+
// Do we need to limit the number of paths? Or batch them into multiple commands?
31+
Paths: { Quantity: constructedPaths.length, Items: constructedPaths },
32+
CallerReference: `${Date.now()}`,
33+
},
34+
}),
35+
);
36+
},
37+
} satisfies CDNInvalidationHandler;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { CDNInvalidationHandler } from "types/overrides";
2+
3+
export default {
4+
name: "dummy",
5+
invalidatePaths: (_) => {
6+
return Promise.resolve();
7+
},
8+
} satisfies CDNInvalidationHandler;

packages/open-next/src/plugins/resolve.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface IPluginSettings {
2828
| IncludedOriginResolver;
2929
warmer?: LazyLoadedOverride<Warmer> | IncludedWarmer;
3030
proxyExternalRequest?: OverrideOptions["proxyExternalRequest"];
31+
cdnInvalidation?: OverrideOptions["cdnInvalidation"];
3132
};
3233
fnName?: string;
3334
}
@@ -53,6 +54,7 @@ const nameToFolder = {
5354
originResolver: "originResolver",
5455
warmer: "warmer",
5556
proxyExternalRequest: "proxyExternalRequest",
57+
cdnInvalidation: "cdnInvalidation",
5658
};
5759

5860
const defaultOverrides = {
@@ -65,6 +67,7 @@ const defaultOverrides = {
6567
originResolver: "pattern-env",
6668
warmer: "aws-lambda",
6769
proxyExternalRequest: "node",
70+
cdnInvalidation: "dummy",
6871
};
6972

7073
/**

0 commit comments

Comments
 (0)