Skip to content

Better support for cloudflare external middleware #449

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all 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/wicked-ligers-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"open-next": patch
---

Better support for cloudflare external middleware
66 changes: 14 additions & 52 deletions packages/open-next/src/adapters/image-optimization-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import https from "node:https";
import path from "node:path";
import { Writable } from "node:stream";

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { loadBuildId, loadConfig } from "config/util.js";
import { OpenNextNodeResponse, StreamCreator } from "http/openNextResponse.js";
// @ts-ignore
Expand All @@ -19,16 +18,14 @@ import {
} from "next/dist/server/image-optimizer";
// @ts-ignore
import type { NextUrlWithParsedQuery } from "next/dist/server/request-meta";
import { ImageLoader, InternalEvent, InternalResult } from "types/open-next.js";
import { InternalEvent, InternalResult } from "types/open-next.js";

import { createGenericHandler } from "../core/createGenericHandler.js";
import { awsLogger, debug, error } from "./logger.js";
import { resolveImageLoader } from "../core/resolve.js";
import { debug, error } from "./logger.js";
import { optimizeImage } from "./plugins/image-optimization/image-optimization.js";
import { setNodeEnv } from "./util.js";

// Expected environment variables
const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env;

setNodeEnv();
const nextDir = path.join(__dirname, ".next");
const config = loadConfig(nextDir);
Expand All @@ -42,7 +39,6 @@ const nextConfig = {
};
debug("Init config", {
nextDir,
BUCKET_NAME,
nextConfig,
});

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

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

const imageParams = validateImageParams(
headers,
Expand Down Expand Up @@ -101,20 +104,6 @@ export async function defaultHandler(
// Helper functions //
//////////////////////

// function normalizeHeaderKeysToLowercase(headers: APIGatewayProxyEventHeaders) {
// // Make header keys lowercase to ensure integrity
// return Object.entries(headers).reduce(
// (acc, [key, value]) => ({ ...acc, [key.toLowerCase()]: value }),
// {} as APIGatewayProxyEventHeaders,
// );
// }

function ensureBucketExists() {
if (!BUCKET_NAME) {
throw new Error("Bucket name must be defined!");
}
}

function validateImageParams(
headers: OutgoingHttpHeaders,
query?: InternalEvent["query"],
Expand Down Expand Up @@ -218,36 +207,9 @@ function buildFailureResponse(
};
}

const resolveLoader = () => {
const openNextParams = globalThis.openNextConfig.imageOptimization;
if (typeof openNextParams?.loader === "function") {
return openNextParams.loader();
} else {
const s3Client = new S3Client({ logger: awsLogger });
return Promise.resolve<ImageLoader>({
name: "s3",
// @ts-ignore
load: async (key: string) => {
ensureBucketExists();
const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, "");
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: keyPrefix
? keyPrefix + "/" + key.replace(/^\//, "")
: key.replace(/^\//, ""),
}),
);
return {
body: response.Body,
contentType: response.ContentType,
cacheControl: response.CacheControl,
};
},
});
}
};
const loader = await resolveLoader();
const loader = await resolveImageLoader(
globalThis.openNextConfig.imageOptimization?.loader ?? "s3",
);

async function downloadHandler(
_req: IncomingMessage,
Expand Down
7 changes: 6 additions & 1 deletion packages/open-next/src/adapters/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ const resolveOriginResolver = () => {
return origin[key];
}
}
if (_path.startsWith("/_next/image") && origin["imageOptimizer"]) {
debug("Using origin", "imageOptimizer", _path);
return origin["imageOptimizer"];
}
if (origin["default"]) {
debug("Using default origin", origin["default"]);
debug("Using default origin", origin["default"], _path);
return origin["default"];
}
return false as const;
Expand All @@ -65,6 +69,7 @@ const defaultHandler = async (internalEvent: InternalEvent) => {
internalEvent: result.internalEvent,
isExternalRewrite: result.isExternalRewrite,
origin,
isISR: result.isISR,
};
} else {
debug("Middleware response", result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const preprocessResult: MiddlewareOutputEvent = {
internalEvent: internalEvent,
isExternalRewrite: false,
origin: false,
isISR: false,
};
//#endOverride

Expand Down
7 changes: 6 additions & 1 deletion packages/open-next/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ async function createImageOptimizationBundle(config: OpenNextConfig) {
overrides: {
converter: config.imageOptimization?.override?.converter,
wrapper: config.imageOptimization?.override?.wrapper,
imageLoader: config.imageOptimization?.loader,
},
}),
];
Expand Down Expand Up @@ -726,7 +727,11 @@ async function createMiddleware() {
fs.mkdirSync(outputPath, { recursive: true });

// Copy open-next.config.mjs
copyOpenNextConfig(options.tempDir, outputPath);
copyOpenNextConfig(
options.tempDir,
outputPath,
config.middleware.override?.wrapper === "cloudflare",
);

// Bundle middleware
await buildEdgeBundle({
Expand Down
22 changes: 19 additions & 3 deletions packages/open-next/src/converters/edge.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Buffer } from "node:buffer";

import { parseCookies } from "http/util";
import { Converter, InternalEvent, InternalResult } from "types/open-next";

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

return {
type: "core",
method: event.method,
method,
rawPath,
url: event.url,
body: event.method !== "GET" ? Buffer.from(body) : undefined,
body: shouldHaveBody ? Buffer.from(body) : undefined,
headers: headers,
remoteAddress: (event.headers.get("x-forwarded-for") as string) ?? "::1",
query,
Expand Down Expand Up @@ -68,7 +72,19 @@ const converter: Converter<
},
});

return fetch(req);
const cfCache =
(result.isISR ||
result.internalEvent.rawPath.startsWith("/_next/image")) &&
process.env.DISABLE_CACHE !== "true"
? { cacheEverything: true }
: {};

return fetch(req, {
// This is a hack to make sure that the response is cached by Cloudflare
// See https://developers.cloudflare.com/workers/examples/cache-using-fetch/#caching-html-resources
// @ts-expect-error - This is a Cloudflare specific option
cf: cfCache,
});
} else {
const headers = new Headers();
for (const [key, value] of Object.entries(result.headers)) {
Expand Down
1 change: 1 addition & 0 deletions packages/open-next/src/core/requestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export async function openNextHandler(
internalEvent: internalEvent,
isExternalRewrite: false,
origin: false,
isISR: false,
};
try {
preprocessResult = await routingHandler(internalEvent);
Expand Down
18 changes: 18 additions & 0 deletions packages/open-next/src/core/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import {
BaseEventOrResult,
Converter,
DefaultOverrideOptions,
ImageLoader,
InternalEvent,
InternalResult,
LazyLoadedOverride,
OverrideOptions,
Wrapper,
} from "types/open-next.js";
Expand Down Expand Up @@ -88,3 +90,19 @@ export async function resolveIncrementalCache(
return m_1.default;
}
}

/**
* @param imageLoader
* @returns
* @__PURE__
*/
export async function resolveImageLoader(
imageLoader: LazyLoadedOverride<ImageLoader> | string,
) {
if (typeof imageLoader === "function") {
return imageLoader();
} else {
const m_1 = await import("../overrides/imageLoader/s3.js");
return m_1.default;
}
}
25 changes: 16 additions & 9 deletions packages/open-next/src/core/routing/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export function fixDataPage(
export function handleFallbackFalse(
internalEvent: InternalEvent,
prerenderManifest: PrerenderManifest,
): InternalEvent {
): { event: InternalEvent; isISR: boolean } {
const { rawPath } = internalEvent;
const { dynamicRoutes, routes } = prerenderManifest;
const routeFallback = Object.entries(dynamicRoutes)
Expand All @@ -365,17 +365,24 @@ export function handleFallbackFalse(
const localizedPath = routesAlreadyHaveLocale
? rawPath
: `/${NextConfig.i18n?.defaultLocale}${rawPath}`;
if (routeFallback && !Object.keys(routes).includes(localizedPath)) {
const isPregenerated = Object.keys(routes).includes(localizedPath);
if (routeFallback && !isPregenerated) {
return {
...internalEvent,
rawPath: "/404",
url: "/404",
headers: {
...internalEvent.headers,
"x-invoke-status": "404",
event: {
...internalEvent,
rawPath: "/404",
url: "/404",
headers: {
...internalEvent.headers,
"x-invoke-status": "404",
},
},
isISR: false,
};
}

return internalEvent;
return {
event: internalEvent,
isISR: routeFallback || isPregenerated,
};
}
11 changes: 10 additions & 1 deletion packages/open-next/src/core/routingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface MiddlewareOutputEvent {
internalEvent: InternalEvent;
isExternalRewrite: boolean;
origin: Origin | false;
isISR: boolean;
}

const staticRegexp = RoutesManifest.routes.static.map(
Expand Down Expand Up @@ -84,7 +85,11 @@ export default async function routingHandler(
}

// We want to run this just before the dynamic route check
internalEvent = handleFallbackFalse(internalEvent, PrerenderManifest);
const { event: fallbackEvent, isISR } = handleFallbackFalse(
internalEvent,
PrerenderManifest,
);
internalEvent = fallbackEvent;

const isDynamicRoute =
!isExternalRewrite &&
Expand All @@ -108,6 +113,8 @@ export default async function routingHandler(
internalEvent.rawPath === "/api" ||
internalEvent.rawPath.startsWith("/api/");

const isNextImageRoute = internalEvent.rawPath.startsWith("/_next/image");

const isRouteFoundBeforeAllRewrites =
isStaticRoute || isDynamicRoute || isExternalRewrite;

Expand All @@ -116,6 +123,7 @@ export default async function routingHandler(
if (
!isRouteFoundBeforeAllRewrites &&
!isApiRoute &&
!isNextImageRoute &&
// We need to check again once all rewrites have been applied
!staticRegexp.some((route) =>
route.test((internalEvent as InternalEvent).rawPath),
Expand Down Expand Up @@ -154,5 +162,6 @@ export default async function routingHandler(
internalEvent,
isExternalRewrite,
origin: false,
isISR,
};
}
4 changes: 3 additions & 1 deletion packages/open-next/src/http/openNextResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse {
if (!this.headersSent) {
this.flushHeaders();
}
// In some cases we might not have a store i.e. for example in the image optimization function
// We may want to reconsider this in the future, it might be intersting to have access to this store everywhere
globalThis.__als
.getStore()
?.getStore()
?.pendingPromiseRunner.add(onEnd(this.headers));
const bodyLength = this.body.length;
this.streamCreator?.onFinish(bodyLength);
Expand Down
35 changes: 35 additions & 0 deletions packages/open-next/src/overrides/imageLoader/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Readable } from "node:stream";
import { ReadableStream } from "node:stream/web";

import { ImageLoader } from "types/open-next";
import { FatalError } from "utils/error";

const hostLoader: ImageLoader = {
name: "host",
load: async (key: string) => {
const host = process.env.HOST;
if (!host) {
throw new FatalError("Host must be defined!");
}
const url = `https://${host}${key}`;
const response = await fetch(url);
if (!response.ok) {
throw new FatalError(`Failed to fetch image from ${url}`);
}
if (!response.body) {
throw new FatalError("No body in response");
}
const body = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
const contentType = response.headers.get("content-type") ?? "image/jpeg";
const cacheControl =
response.headers.get("cache-control") ??
"private, max-age=0, must-revalidate";
return {
body,
contentType,
cacheControl,
};
},
};

export default hostLoader;
Loading
Loading