Skip to content

Commit 579f9eb

Browse files
authored
Better support for cloudflare external middleware (#449)
* fix open-next config issue * handle next image in external middleware * make cache work with ISR/SSG * fix buffer issues head request * add host image loader * add comment * Create wicked-ligers-speak.md
1 parent 9285014 commit 579f9eb

File tree

16 files changed

+201
-70
lines changed

16 files changed

+201
-70
lines changed

.changeset/wicked-ligers-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"open-next": patch
3+
---
4+
5+
Better support for cloudflare external middleware

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

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import https from "node:https";
88
import path from "node:path";
99
import { Writable } from "node:stream";
1010

11-
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
1211
import { loadBuildId, loadConfig } from "config/util.js";
1312
import { OpenNextNodeResponse, StreamCreator } from "http/openNextResponse.js";
1413
// @ts-ignore
@@ -19,16 +18,14 @@ import {
1918
} from "next/dist/server/image-optimizer";
2019
// @ts-ignore
2120
import type { NextUrlWithParsedQuery } from "next/dist/server/request-meta";
22-
import { ImageLoader, InternalEvent, InternalResult } from "types/open-next.js";
21+
import { InternalEvent, InternalResult } from "types/open-next.js";
2322

2423
import { createGenericHandler } from "../core/createGenericHandler.js";
25-
import { awsLogger, debug, error } from "./logger.js";
24+
import { resolveImageLoader } from "../core/resolve.js";
25+
import { debug, error } from "./logger.js";
2626
import { optimizeImage } from "./plugins/image-optimization/image-optimization.js";
2727
import { setNodeEnv } from "./util.js";
2828

29-
// Expected environment variables
30-
const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env;
31-
3229
setNodeEnv();
3330
const nextDir = path.join(__dirname, ".next");
3431
const config = loadConfig(nextDir);
@@ -42,7 +39,6 @@ const nextConfig = {
4239
};
4340
debug("Init config", {
4441
nextDir,
45-
BUCKET_NAME,
4642
nextConfig,
4743
});
4844

@@ -64,7 +60,14 @@ export async function defaultHandler(
6460
const { headers, query: queryString } = event;
6561

6662
try {
67-
// const headers = normalizeHeaderKeysToLowercase(rawHeaders);
63+
// Set the HOST environment variable to the host header if it is not set
64+
// If it is set it is assumed to be set by the user and should be used instead
65+
// It might be useful for cases where the user wants to use a different host than the one in the request
66+
// It could even allow to have multiple hosts for the image optimization by setting the HOST environment variable in the wrapper for example
67+
if (!process.env.HOST) {
68+
const headersHost = headers["x-forwarded-host"] || headers["host"];
69+
process.env.HOST = headersHost;
70+
}
6871

6972
const imageParams = validateImageParams(
7073
headers,
@@ -101,20 +104,6 @@ export async function defaultHandler(
101104
// Helper functions //
102105
//////////////////////
103106

104-
// function normalizeHeaderKeysToLowercase(headers: APIGatewayProxyEventHeaders) {
105-
// // Make header keys lowercase to ensure integrity
106-
// return Object.entries(headers).reduce(
107-
// (acc, [key, value]) => ({ ...acc, [key.toLowerCase()]: value }),
108-
// {} as APIGatewayProxyEventHeaders,
109-
// );
110-
// }
111-
112-
function ensureBucketExists() {
113-
if (!BUCKET_NAME) {
114-
throw new Error("Bucket name must be defined!");
115-
}
116-
}
117-
118107
function validateImageParams(
119108
headers: OutgoingHttpHeaders,
120109
query?: InternalEvent["query"],
@@ -218,36 +207,9 @@ function buildFailureResponse(
218207
};
219208
}
220209

221-
const resolveLoader = () => {
222-
const openNextParams = globalThis.openNextConfig.imageOptimization;
223-
if (typeof openNextParams?.loader === "function") {
224-
return openNextParams.loader();
225-
} else {
226-
const s3Client = new S3Client({ logger: awsLogger });
227-
return Promise.resolve<ImageLoader>({
228-
name: "s3",
229-
// @ts-ignore
230-
load: async (key: string) => {
231-
ensureBucketExists();
232-
const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, "");
233-
const response = await s3Client.send(
234-
new GetObjectCommand({
235-
Bucket: BUCKET_NAME,
236-
Key: keyPrefix
237-
? keyPrefix + "/" + key.replace(/^\//, "")
238-
: key.replace(/^\//, ""),
239-
}),
240-
);
241-
return {
242-
body: response.Body,
243-
contentType: response.ContentType,
244-
cacheControl: response.CacheControl,
245-
};
246-
},
247-
});
248-
}
249-
};
250-
const loader = await resolveLoader();
210+
const loader = await resolveImageLoader(
211+
globalThis.openNextConfig.imageOptimization?.loader ?? "s3",
212+
);
251213

252214
async function downloadHandler(
253215
_req: IncomingMessage,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,12 @@ const resolveOriginResolver = () => {
3737
return origin[key];
3838
}
3939
}
40+
if (_path.startsWith("/_next/image") && origin["imageOptimizer"]) {
41+
debug("Using origin", "imageOptimizer", _path);
42+
return origin["imageOptimizer"];
43+
}
4044
if (origin["default"]) {
41-
debug("Using default origin", origin["default"]);
45+
debug("Using default origin", origin["default"], _path);
4246
return origin["default"];
4347
}
4448
return false as const;
@@ -65,6 +69,7 @@ const defaultHandler = async (internalEvent: InternalEvent) => {
6569
internalEvent: result.internalEvent,
6670
isExternalRewrite: result.isExternalRewrite,
6771
origin,
72+
isISR: result.isISR,
6873
};
6974
} else {
7075
debug("Middleware response", result);

packages/open-next/src/adapters/plugins/without-routing/requestHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const preprocessResult: MiddlewareOutputEvent = {
99
internalEvent: internalEvent,
1010
isExternalRewrite: false,
1111
origin: false,
12+
isISR: false,
1213
};
1314
//#endOverride
1415

packages/open-next/src/build.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ async function createImageOptimizationBundle(config: OpenNextConfig) {
324324
overrides: {
325325
converter: config.imageOptimization?.override?.converter,
326326
wrapper: config.imageOptimization?.override?.wrapper,
327+
imageLoader: config.imageOptimization?.loader,
327328
},
328329
}),
329330
];
@@ -726,7 +727,11 @@ async function createMiddleware() {
726727
fs.mkdirSync(outputPath, { recursive: true });
727728

728729
// Copy open-next.config.mjs
729-
copyOpenNextConfig(options.tempDir, outputPath);
730+
copyOpenNextConfig(
731+
options.tempDir,
732+
outputPath,
733+
config.middleware.override?.wrapper === "cloudflare",
734+
);
730735

731736
// Bundle middleware
732737
await buildEdgeBundle({

packages/open-next/src/converters/edge.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Buffer } from "node:buffer";
2+
13
import { parseCookies } from "http/util";
24
import { Converter, InternalEvent, InternalResult } from "types/open-next";
35

@@ -28,13 +30,15 @@ const converter: Converter<
2830
headers[key] = value;
2931
});
3032
const rawPath = new URL(event.url).pathname;
33+
const method = event.method;
34+
const shouldHaveBody = method !== "GET" && method !== "HEAD";
3135

3236
return {
3337
type: "core",
34-
method: event.method,
38+
method,
3539
rawPath,
3640
url: event.url,
37-
body: event.method !== "GET" ? Buffer.from(body) : undefined,
41+
body: shouldHaveBody ? Buffer.from(body) : undefined,
3842
headers: headers,
3943
remoteAddress: (event.headers.get("x-forwarded-for") as string) ?? "::1",
4044
query,
@@ -68,7 +72,19 @@ const converter: Converter<
6872
},
6973
});
7074

71-
return fetch(req);
75+
const cfCache =
76+
(result.isISR ||
77+
result.internalEvent.rawPath.startsWith("/_next/image")) &&
78+
process.env.DISABLE_CACHE !== "true"
79+
? { cacheEverything: true }
80+
: {};
81+
82+
return fetch(req, {
83+
// This is a hack to make sure that the response is cached by Cloudflare
84+
// See https://developers.cloudflare.com/workers/examples/cache-using-fetch/#caching-html-resources
85+
// @ts-expect-error - This is a Cloudflare specific option
86+
cf: cfCache,
87+
});
7288
} else {
7389
const headers = new Headers();
7490
for (const [key, value] of Object.entries(result.headers)) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export async function openNextHandler(
3333
internalEvent: internalEvent,
3434
isExternalRewrite: false,
3535
origin: false,
36+
isISR: false,
3637
};
3738
try {
3839
preprocessResult = await routingHandler(internalEvent);

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import {
22
BaseEventOrResult,
33
Converter,
44
DefaultOverrideOptions,
5+
ImageLoader,
56
InternalEvent,
67
InternalResult,
8+
LazyLoadedOverride,
79
OverrideOptions,
810
Wrapper,
911
} from "types/open-next.js";
@@ -88,3 +90,19 @@ export async function resolveIncrementalCache(
8890
return m_1.default;
8991
}
9092
}
93+
94+
/**
95+
* @param imageLoader
96+
* @returns
97+
* @__PURE__
98+
*/
99+
export async function resolveImageLoader(
100+
imageLoader: LazyLoadedOverride<ImageLoader> | string,
101+
) {
102+
if (typeof imageLoader === "function") {
103+
return imageLoader();
104+
} else {
105+
const m_1 = await import("../overrides/imageLoader/s3.js");
106+
return m_1.default;
107+
}
108+
}

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

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export function fixDataPage(
348348
export function handleFallbackFalse(
349349
internalEvent: InternalEvent,
350350
prerenderManifest: PrerenderManifest,
351-
): InternalEvent {
351+
): { event: InternalEvent; isISR: boolean } {
352352
const { rawPath } = internalEvent;
353353
const { dynamicRoutes, routes } = prerenderManifest;
354354
const routeFallback = Object.entries(dynamicRoutes)
@@ -365,17 +365,24 @@ export function handleFallbackFalse(
365365
const localizedPath = routesAlreadyHaveLocale
366366
? rawPath
367367
: `/${NextConfig.i18n?.defaultLocale}${rawPath}`;
368-
if (routeFallback && !Object.keys(routes).includes(localizedPath)) {
368+
const isPregenerated = Object.keys(routes).includes(localizedPath);
369+
if (routeFallback && !isPregenerated) {
369370
return {
370-
...internalEvent,
371-
rawPath: "/404",
372-
url: "/404",
373-
headers: {
374-
...internalEvent.headers,
375-
"x-invoke-status": "404",
371+
event: {
372+
...internalEvent,
373+
rawPath: "/404",
374+
url: "/404",
375+
headers: {
376+
...internalEvent.headers,
377+
"x-invoke-status": "404",
378+
},
376379
},
380+
isISR: false,
377381
};
378382
}
379383

380-
return internalEvent;
384+
return {
385+
event: internalEvent,
386+
isISR: routeFallback || isPregenerated,
387+
};
381388
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface MiddlewareOutputEvent {
2020
internalEvent: InternalEvent;
2121
isExternalRewrite: boolean;
2222
origin: Origin | false;
23+
isISR: boolean;
2324
}
2425

2526
// Add the locale prefix to the regex so we correctly match the rawPath
@@ -90,7 +91,11 @@ export default async function routingHandler(
9091
}
9192

9293
// We want to run this just before the dynamic route check
93-
internalEvent = handleFallbackFalse(internalEvent, PrerenderManifest);
94+
const { event: fallbackEvent, isISR } = handleFallbackFalse(
95+
internalEvent,
96+
PrerenderManifest,
97+
);
98+
internalEvent = fallbackEvent;
9499

95100
const isDynamicRoute =
96101
!isExternalRewrite &&
@@ -114,6 +119,8 @@ export default async function routingHandler(
114119
internalEvent.rawPath === "/api" ||
115120
internalEvent.rawPath.startsWith("/api/");
116121

122+
const isNextImageRoute = internalEvent.rawPath.startsWith("/_next/image");
123+
117124
const isRouteFoundBeforeAllRewrites =
118125
isStaticRoute || isDynamicRoute || isExternalRewrite;
119126

@@ -122,6 +129,7 @@ export default async function routingHandler(
122129
if (
123130
!isRouteFoundBeforeAllRewrites &&
124131
!isApiRoute &&
132+
!isNextImageRoute &&
125133
// We need to check again once all rewrites have been applied
126134
!staticRegexp.some((route) =>
127135
route.test((internalEvent as InternalEvent).rawPath),
@@ -160,5 +168,6 @@ export default async function routingHandler(
160168
internalEvent,
161169
isExternalRewrite,
162170
origin: false,
171+
isISR,
163172
};
164173
}

packages/open-next/src/http/openNextResponse.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse {
9191
if (!this.headersSent) {
9292
this.flushHeaders();
9393
}
94+
// In some cases we might not have a store i.e. for example in the image optimization function
95+
// We may want to reconsider this in the future, it might be intersting to have access to this store everywhere
9496
globalThis.__als
95-
.getStore()
97+
?.getStore()
9698
?.pendingPromiseRunner.add(onEnd(this.headers));
9799
const bodyLength = this.body.length;
98100
this.streamCreator?.onFinish(bodyLength);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Readable } from "node:stream";
2+
import { ReadableStream } from "node:stream/web";
3+
4+
import { ImageLoader } from "types/open-next";
5+
import { FatalError } from "utils/error";
6+
7+
const hostLoader: ImageLoader = {
8+
name: "host",
9+
load: async (key: string) => {
10+
const host = process.env.HOST;
11+
if (!host) {
12+
throw new FatalError("Host must be defined!");
13+
}
14+
const url = `https://${host}${key}`;
15+
const response = await fetch(url);
16+
if (!response.ok) {
17+
throw new FatalError(`Failed to fetch image from ${url}`);
18+
}
19+
if (!response.body) {
20+
throw new FatalError("No body in response");
21+
}
22+
const body = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
23+
const contentType = response.headers.get("content-type") ?? "image/jpeg";
24+
const cacheControl =
25+
response.headers.get("cache-control") ??
26+
"private, max-age=0, must-revalidate";
27+
return {
28+
body,
29+
contentType,
30+
cacheControl,
31+
};
32+
},
33+
};
34+
35+
export default hostLoader;

0 commit comments

Comments
 (0)