Skip to content

Commit 855cf92

Browse files
conico974fwang
andauthored
ISR support (#58)
* feat: inject IncrementalCache * fix: check cache keys exists * feat: revalidate isr on background * feat: added buildId in cache key * feat: copy cache files * fix: don't wait for the end of the request * fix: workaround to force next to stop revalidation * feat: added a cache interceptor to speed up cold start for ISR and SSG * feat: add support for app dir in cacheInterceptor * fix: several bug fix - Cache interceptor set cache-control to 1 year if revalidate=0 or false - Background revalidation doesn't rely on headers from client anymore - Added region to s3Client to allow edge to use cache * fix: enable cache interception with an env variable * feat: added support for fetch cache * feat: SQS based revalidation * docs: updated readme for ISR * feat: remove unnecessary html files from the bundle * fix: return string for rfc page data instead of buffer * docs: added disclaimer about 13.4 issues * feat: removed unnecessary cache interceptor * fix: remove meta files from bundle * feat: add support for cloudfront stale-while-revalidate * Sync * Sync * Sync * Add AWS sdk logger * Doc * Sync * Sync * Sync --------- Co-authored-by: Frank <[email protected]>
1 parent 1ee6825 commit 855cf92

File tree

14 files changed

+1764
-1494
lines changed

14 files changed

+1764
-1494
lines changed

.changeset/wild-bees-wait.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"open-next": major
3+
---
4+
5+
Improved ISR support

README.md

Lines changed: 181 additions & 24 deletions
Large diffs are not rendered by default.

docs/public/architecture.png

308 KB
Loading

packages/open-next/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@
3434
"README.md"
3535
],
3636
"dependencies": {
37-
"@aws-sdk/client-lambda": "^3.234.0",
38-
"@aws-sdk/client-s3": "^3.234.0",
37+
"@aws-sdk/client-lambda": "^3.312.0",
38+
"@aws-sdk/client-s3": "^3.312.0",
39+
"@aws-sdk/client-sqs": "^3.312.0",
3940
"@node-minify/core": "^8.0.6",
4041
"@node-minify/terser": "^8.0.6",
4142
"@tsconfig/node18": "^1.0.1",
4243
"esbuild": "^0.15.18",
43-
"promise.series": "^0.2.0",
44-
"yargs": "^17.6.2"
44+
"promise.series": "^0.2.0"
4545
},
4646
"devDependencies": {
4747
"@types/aws-lambda": "^8.10.109",
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import {
2+
S3Client,
3+
GetObjectCommand,
4+
PutObjectCommand,
5+
PutObjectCommandInput,
6+
ListObjectsV2Command,
7+
} from "@aws-sdk/client-s3";
8+
import path from "node:path";
9+
import { error, awsLogger } from "./logger.js";
10+
import { loadBuildId } from "./util.js";
11+
12+
interface CachedFetchValue {
13+
kind: "FETCH";
14+
data: {
15+
headers: { [k: string]: string };
16+
body: string;
17+
status?: number;
18+
};
19+
revalidate: number;
20+
}
21+
22+
interface CachedRedirectValue {
23+
kind: "REDIRECT";
24+
props: Object;
25+
}
26+
27+
interface CachedRouteValue {
28+
kind: "ROUTE";
29+
// this needs to be a RenderResult so since renderResponse
30+
// expects that type instead of a string
31+
body: Buffer;
32+
status: number;
33+
headers: Record<string, undefined | string | string[]>;
34+
}
35+
36+
interface CachedImageValue {
37+
kind: "IMAGE";
38+
etag: string;
39+
buffer: Buffer;
40+
extension: string;
41+
isMiss?: boolean;
42+
isStale?: boolean;
43+
}
44+
45+
interface IncrementalCachedPageValue {
46+
kind: "PAGE";
47+
// this needs to be a string since the cache expects to store
48+
// the string value
49+
html: string;
50+
pageData: Object;
51+
}
52+
53+
type IncrementalCacheValue =
54+
| CachedRedirectValue
55+
| IncrementalCachedPageValue
56+
| CachedImageValue
57+
| CachedFetchValue
58+
| CachedRouteValue;
59+
60+
interface CacheHandlerContext {
61+
fs?: never;
62+
dev?: boolean;
63+
flushToDisk?: boolean;
64+
serverDistDir?: string;
65+
maxMemoryCacheSize?: number;
66+
_appDir: boolean;
67+
_requestHeaders: never;
68+
fetchCacheKeyPrefix?: string;
69+
}
70+
71+
interface CacheHandlerValue {
72+
lastModified?: number;
73+
age?: number;
74+
cacheState?: string;
75+
value: IncrementalCacheValue | null;
76+
}
77+
78+
type Extension = "json" | "html" | "rsc" | "body" | "meta" | "fetch";
79+
80+
// Expected environment variables
81+
const { CACHE_BUCKET_NAME, CACHE_BUCKET_KEY_PREFIX, CACHE_BUCKET_REGION } =
82+
process.env;
83+
84+
export default class S3Cache {
85+
private client: S3Client;
86+
private buildId: string;
87+
88+
constructor(_ctx: CacheHandlerContext) {
89+
this.client = new S3Client({
90+
region: CACHE_BUCKET_REGION,
91+
logger: awsLogger,
92+
});
93+
this.buildId = loadBuildId(
94+
path.dirname(_ctx.serverDistDir ?? ".next/server")
95+
);
96+
}
97+
98+
async get(key: string, fetchCache?: boolean) {
99+
return fetchCache ? this.getFetchCache(key) : this.getIncrementalCache(key);
100+
}
101+
102+
async getFetchCache(key: string) {
103+
try {
104+
const { Body, LastModified } = await this.getS3Object(key, "fetch");
105+
return {
106+
lastModified: LastModified?.getTime(),
107+
value: JSON.parse((await Body?.transformToString()) ?? "{}"),
108+
} as CacheHandlerValue;
109+
} catch (e) {
110+
error("Failed to get fetch cache", e);
111+
return null;
112+
}
113+
}
114+
115+
async getIncrementalCache(key: string): Promise<CacheHandlerValue | null> {
116+
const { Contents } = await this.listS3Objects(key);
117+
const keys = (Contents ?? []).map(({ Key }) => Key);
118+
119+
if (keys.includes(this.buildS3Key(key, "body"))) {
120+
try {
121+
const [{ Body, LastModified }, { Body: MetaBody }] = await Promise.all([
122+
this.getS3Object(key, "body"),
123+
this.getS3Object(key, "meta"),
124+
]);
125+
const body = await Body?.transformToByteArray();
126+
const meta = JSON.parse((await MetaBody?.transformToString()) ?? "{}");
127+
128+
return {
129+
lastModified: LastModified?.getTime(),
130+
value: {
131+
kind: "ROUTE",
132+
body: Buffer.from(body ?? Buffer.alloc(0)),
133+
status: meta.status,
134+
headers: meta.headers,
135+
},
136+
} as CacheHandlerValue;
137+
} catch (e) {
138+
error("Failed to get body cache", e);
139+
}
140+
return null;
141+
}
142+
143+
if (keys.includes(this.buildS3Key(key, "html"))) {
144+
const isJson = keys.includes(this.buildS3Key(key, "json"));
145+
const isRsc = keys.includes(this.buildS3Key(key, "rsc"));
146+
if (!isJson && !isRsc) return null;
147+
148+
try {
149+
const [{ Body, LastModified }, { Body: PageBody }] = await Promise.all([
150+
this.getS3Object(key, "html"),
151+
this.getS3Object(key, isJson ? "json" : "rsc"),
152+
]);
153+
154+
return {
155+
lastModified: LastModified?.getTime(),
156+
value: {
157+
kind: "PAGE",
158+
html: (await Body?.transformToString()) ?? "",
159+
pageData: isJson
160+
? JSON.parse((await PageBody?.transformToString()) ?? "{}")
161+
: await PageBody?.transformToString(),
162+
},
163+
} as CacheHandlerValue;
164+
} catch (e) {
165+
error("Failed to get html cache", e);
166+
}
167+
return null;
168+
}
169+
return null;
170+
}
171+
172+
async set(key: string, data?: IncrementalCacheValue): Promise<void> {
173+
if (data?.kind === "ROUTE") {
174+
const { body, status, headers } = data;
175+
await Promise.all([
176+
this.putS3Object(key, "body", body),
177+
this.putS3Object(key, "meta", JSON.stringify({ status, headers })),
178+
]);
179+
} else if (data?.kind === "PAGE") {
180+
const { html, pageData } = data;
181+
const isAppPath = typeof pageData === "string";
182+
await Promise.all([
183+
this.putS3Object(key, "html", html),
184+
this.putS3Object(
185+
key,
186+
isAppPath ? "rsc" : "json",
187+
isAppPath ? pageData : JSON.stringify(pageData)
188+
),
189+
]);
190+
} else if (data?.kind === "FETCH") {
191+
await this.putS3Object(key, "fetch", JSON.stringify(data));
192+
}
193+
}
194+
195+
private buildS3Key(key: string, extension: Extension) {
196+
return path.posix.join(
197+
CACHE_BUCKET_KEY_PREFIX ?? "",
198+
extension === "fetch" ? "__fetch" : "",
199+
this.buildId,
200+
extension === "fetch" ? key : `${key}.${extension}`
201+
);
202+
}
203+
204+
private buildS3KeyPrefix(key: string) {
205+
return path.posix.join(CACHE_BUCKET_KEY_PREFIX ?? "", this.buildId, key);
206+
}
207+
208+
private listS3Objects(key: string) {
209+
return this.client.send(
210+
new ListObjectsV2Command({
211+
Bucket: CACHE_BUCKET_NAME,
212+
Prefix: this.buildS3KeyPrefix(key),
213+
})
214+
);
215+
}
216+
217+
private getS3Object(key: string, extension: Extension) {
218+
return this.client.send(
219+
new GetObjectCommand({
220+
Bucket: CACHE_BUCKET_NAME,
221+
Key: this.buildS3Key(key, extension),
222+
})
223+
);
224+
}
225+
226+
private putS3Object(
227+
key: string,
228+
extension: Extension,
229+
value: PutObjectCommandInput["Body"]
230+
) {
231+
return this.client.send(
232+
new PutObjectCommand({
233+
Bucket: CACHE_BUCKET_NAME,
234+
Key: this.buildS3Key(key, extension),
235+
Body: value,
236+
})
237+
);
238+
}
239+
}

packages/open-next/src/adapters/image-optimization-adapter.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ import {
2020
// @ts-ignore
2121
} from "next/dist/server/image-optimizer";
2222
import { loadConfig, setNodeEnv } from "./util.js";
23-
import { debug } from "./logger.js";
23+
import { debug, error, awsLogger } from "./logger.js";
24+
25+
// Expected environment variables
26+
const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env;
27+
28+
const s3Client = new S3Client({ logger: awsLogger });
2429

2530
setNodeEnv();
26-
const bucketName = process.env.BUCKET_NAME;
2731
const nextDir = path.join(__dirname, ".next");
2832
const config = loadConfig(nextDir);
2933
const nextConfig = {
@@ -35,7 +39,7 @@ const nextConfig = {
3539
};
3640
debug("Init config", {
3741
nextDir,
38-
bucketName,
42+
BUCKET_NAME,
3943
nextConfig,
4044
});
4145

@@ -78,7 +82,7 @@ function normalizeHeaderKeysToLowercase(headers: APIGatewayProxyEventHeaders) {
7882
}
7983

8084
function ensureBucketExists() {
81-
if (!bucketName) {
85+
if (!BUCKET_NAME) {
8286
throw new Error("Bucket name must be defined!");
8387
}
8488
}
@@ -162,7 +166,7 @@ async function downloadHandler(
162166
res.end();
163167
})
164168
.once("error", (err) => {
165-
console.error("Failed to get image", { err });
169+
error("Failed to get image", err);
166170
res.statusCode = 400;
167171
res.end();
168172
});
@@ -177,11 +181,13 @@ async function downloadHandler(
177181
else {
178182
// Download image from S3
179183
// note: S3 expects keys without leading `/`
180-
const client = new S3Client({});
181-
const response = await client.send(
184+
const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, "");
185+
const response = await s3Client.send(
182186
new GetObjectCommand({
183-
Bucket: bucketName,
184-
Key: url.href.replace(/^\//, ""),
187+
Bucket: BUCKET_NAME,
188+
Key: keyPrefix
189+
? keyPrefix + "/" + url.href.replace(/^\//, "")
190+
: url.href.replace(/^\//, ""),
185191
})
186192
);
187193

@@ -202,7 +208,7 @@ async function downloadHandler(
202208
}
203209
}
204210
} catch (e: any) {
205-
console.error("Failed to download image", e);
211+
error("Failed to download image", e);
206212
throw e;
207213
}
208214
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,19 @@ export function debug(...args: any[]) {
33
console.log(...args);
44
}
55
}
6+
7+
export function warn(...args: any[]) {
8+
console.warn(...args);
9+
}
10+
11+
export function error(...args: any[]) {
12+
console.error(...args);
13+
}
14+
15+
export const awsLogger = {
16+
trace: () => {},
17+
debug: () => {},
18+
info: debug,
19+
warn,
20+
error,
21+
};

packages/open-next/src/adapters/require-hooks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Individually compiled modules are as defined for the compilation in bundles/webpack/packages/*.
44

55
import type { NextConfig } from "./next-types.js";
6+
import { error } from "./logger.js";
67

78
// This module will only be loaded once per process.
89

@@ -16,7 +17,7 @@ export function overrideHooks(config: NextConfig) {
1617
overrideDefault();
1718
overrideReact(config);
1819
} catch (e) {
19-
console.error("Failed to override Next.js require hooks.", e);
20+
error("Failed to override Next.js require hooks.", e);
2021
throw e;
2122
}
2223
}

0 commit comments

Comments
 (0)