Skip to content

Commit

Permalink
Better support for cloudflare external middleware (#449)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
conico974 authored Jun 28, 2024
1 parent 9285014 commit 579f9eb
Show file tree
Hide file tree
Showing 16 changed files with 201 additions and 70 deletions.
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;
}

// Add the locale prefix to the regex so we correctly match the rawPath
Expand Down Expand Up @@ -90,7 +91,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 @@ -114,6 +119,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 @@ -122,6 +129,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 @@ -160,5 +168,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

0 comments on commit 579f9eb

Please sign in to comment.