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

+5
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

+1
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

+26
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

+5
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

+9-2
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

+13
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

+25-1
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+
}
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;
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

+3
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
/**

packages/open-next/src/types/global.ts

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AsyncLocalStorage } from "node:async_hooks";
22
import type { OutgoingHttpHeaders } from "node:http";
33

44
import type {
5+
CDNInvalidationHandler,
56
IncrementalCache,
67
ProxyExternalRequest,
78
Queue,
@@ -206,4 +207,11 @@ declare global {
206207
* Defined in `createMainHandler`.
207208
*/
208209
var proxyExternalRequest: ProxyExternalRequest;
210+
211+
/**
212+
* The function that will be called when the CDN needs invalidating (either from `revalidateTag` or from `res.revalidate`)
213+
* Available in main functions
214+
* Defined in `createMainHandler`
215+
*/
216+
var cdnInvalidationHandler: CDNInvalidationHandler;
209217
}

packages/open-next/src/types/open-next.ts

+11
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ReadableStream } from "node:stream/web";
33
import type { Writable } from "node:stream";
44
import type { WarmerEvent, WarmerResponse } from "../adapters/warmer-function";
55
import type {
6+
CDNInvalidationHandler,
67
Converter,
78
ImageLoader,
89
IncrementalCache,
@@ -157,6 +158,8 @@ export type IncludedWarmer = "aws-lambda" | "dummy";
157158

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

161+
export type IncludedCDNInvalidationHandler = "cloudfront" | "dummy";
162+
160163
export interface DefaultOverrideOptions<
161164
E extends BaseEventOrResult = InternalEvent,
162165
R extends BaseEventOrResult = InternalResult,
@@ -208,6 +211,14 @@ export interface OverrideOptions extends DefaultOverrideOptions {
208211
proxyExternalRequest?:
209212
| IncludedProxyExternalRequest
210213
| LazyLoadedOverride<ProxyExternalRequest>;
214+
215+
/**
216+
* Add possibility to override the default cdn invalidation for On Demand Revalidation
217+
* @default "dummy"
218+
*/
219+
cdnInvalidation?:
220+
| IncludedCDNInvalidationHandler
221+
| LazyLoadedOverride<CDNInvalidationHandler>;
211222
}
212223

213224
export interface InstallOptions {

packages/open-next/src/types/overrides.ts

+11
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
InternalEvent,
99
InternalResult,
1010
Origin,
11+
ResolvedRoute,
1112
StreamCreator,
1213
} from "./open-next";
1314

@@ -139,3 +140,13 @@ export type OriginResolver = BaseOverride & {
139140
export type ProxyExternalRequest = BaseOverride & {
140141
proxy: (event: InternalEvent) => Promise<InternalResult>;
141142
};
143+
144+
type CDNPath = {
145+
initialPath: string;
146+
rawPath: string;
147+
resolvedRoutes: ResolvedRoute[];
148+
};
149+
150+
export type CDNInvalidationHandler = BaseOverride & {
151+
invalidatePaths: (paths: CDNPath[]) => Promise<void>;
152+
};

packages/tests-unit/tests/adapters/cache.test.ts

+75
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { vi } from "vitest";
44

55
declare global {
66
var disableIncrementalCache: boolean;
7+
var disableDynamoDBCache: boolean;
78
var isNextAfter15: boolean;
89
}
910

@@ -39,6 +40,12 @@ describe("CacheHandler", () => {
3940
};
4041
globalThis.tagCache = tagCache;
4142

43+
const invalidateCdnHandler = {
44+
name: "mock",
45+
invalidatePaths: vi.fn(),
46+
};
47+
globalThis.cdnInvalidationHandler = invalidateCdnHandler;
48+
4249
globalThis.__openNextAls = {
4350
getStore: vi.fn().mockReturnValue({
4451
requestId: "123",
@@ -470,4 +477,72 @@ describe("CacheHandler", () => {
470477
).not.toThrow();
471478
});
472479
});
480+
481+
describe("revalidateTag", () => {
482+
beforeEach(() => {
483+
globalThis.disableDynamoDBCache = false;
484+
globalThis.disableIncrementalCache = false;
485+
});
486+
it("Should do nothing if disableIncrementalCache is true", async () => {
487+
globalThis.disableIncrementalCache = true;
488+
489+
await cache.revalidateTag("tag");
490+
491+
expect(tagCache.writeTags).not.toHaveBeenCalled();
492+
});
493+
494+
it("Should do nothing if disableTagCache is true", async () => {
495+
globalThis.disableDynamoDBCache = true;
496+
497+
await cache.revalidateTag("tag");
498+
499+
expect(tagCache.writeTags).not.toHaveBeenCalled();
500+
});
501+
502+
it("Should call tagCache.writeTags", async () => {
503+
globalThis.tagCache.getByTag.mockResolvedValueOnce(["/path"]);
504+
await cache.revalidateTag("tag");
505+
506+
expect(globalThis.tagCache.getByTag).toHaveBeenCalledWith("tag");
507+
508+
expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
509+
expect(tagCache.writeTags).toHaveBeenCalledWith([
510+
{
511+
path: "/path",
512+
tag: "tag",
513+
},
514+
]);
515+
});
516+
517+
it("Should call invalidateCdnHandler.invalidatePaths", async () => {
518+
globalThis.tagCache.getByTag.mockResolvedValueOnce(["/path"]);
519+
globalThis.tagCache.getByPath.mockResolvedValueOnce([]);
520+
await cache.revalidateTag("_N_T_/path");
521+
522+
expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
523+
expect(tagCache.writeTags).toHaveBeenCalledWith([
524+
{
525+
path: "/path",
526+
tag: "_N_T_/path",
527+
},
528+
]);
529+
530+
expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalled();
531+
});
532+
533+
it("Should not call invalidateCdnHandler.invalidatePaths for fetch cache key ", async () => {
534+
globalThis.tagCache.getByTag.mockResolvedValueOnce(["123456"]);
535+
await cache.revalidateTag("tag");
536+
537+
expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
538+
expect(tagCache.writeTags).toHaveBeenCalledWith([
539+
{
540+
path: "123456",
541+
tag: "tag",
542+
},
543+
]);
544+
545+
expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled();
546+
});
547+
});
473548
});

0 commit comments

Comments
 (0)