Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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).toHaveBeenCalled();
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).toHaveBeenCalled();
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).toHaveBeenCalled();
expect(tagCache.writeTags).toHaveBeenCalledWith([
{
path: "123456",
tag: "tag",
},
]);

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