diff --git a/.changeset/wicked-ligers-speak.md b/.changeset/wicked-ligers-speak.md new file mode 100644 index 00000000..ecd39e2b --- /dev/null +++ b/.changeset/wicked-ligers-speak.md @@ -0,0 +1,5 @@ +--- +"open-next": patch +--- + +Better support for cloudflare external middleware diff --git a/packages/open-next/src/adapters/image-optimization-adapter.ts b/packages/open-next/src/adapters/image-optimization-adapter.ts index e4f5974e..a589c339 100644 --- a/packages/open-next/src/adapters/image-optimization-adapter.ts +++ b/packages/open-next/src/adapters/image-optimization-adapter.ts @@ -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 @@ -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); @@ -42,7 +39,6 @@ const nextConfig = { }; debug("Init config", { nextDir, - BUCKET_NAME, nextConfig, }); @@ -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, @@ -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"], @@ -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({ - 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, diff --git a/packages/open-next/src/adapters/middleware.ts b/packages/open-next/src/adapters/middleware.ts index 8f6ca05b..192778b3 100644 --- a/packages/open-next/src/adapters/middleware.ts +++ b/packages/open-next/src/adapters/middleware.ts @@ -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; @@ -65,6 +69,7 @@ const defaultHandler = async (internalEvent: InternalEvent) => { internalEvent: result.internalEvent, isExternalRewrite: result.isExternalRewrite, origin, + isISR: result.isISR, }; } else { debug("Middleware response", result); diff --git a/packages/open-next/src/adapters/plugins/without-routing/requestHandler.ts b/packages/open-next/src/adapters/plugins/without-routing/requestHandler.ts index 5d2cbc51..8d477668 100644 --- a/packages/open-next/src/adapters/plugins/without-routing/requestHandler.ts +++ b/packages/open-next/src/adapters/plugins/without-routing/requestHandler.ts @@ -9,6 +9,7 @@ const preprocessResult: MiddlewareOutputEvent = { internalEvent: internalEvent, isExternalRewrite: false, origin: false, + isISR: false, }; //#endOverride diff --git a/packages/open-next/src/build.ts b/packages/open-next/src/build.ts index 0fcbe7b1..42f9497f 100755 --- a/packages/open-next/src/build.ts +++ b/packages/open-next/src/build.ts @@ -324,6 +324,7 @@ async function createImageOptimizationBundle(config: OpenNextConfig) { overrides: { converter: config.imageOptimization?.override?.converter, wrapper: config.imageOptimization?.override?.wrapper, + imageLoader: config.imageOptimization?.loader, }, }), ]; @@ -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({ diff --git a/packages/open-next/src/converters/edge.ts b/packages/open-next/src/converters/edge.ts index 32d5923e..aca0d42f 100644 --- a/packages/open-next/src/converters/edge.ts +++ b/packages/open-next/src/converters/edge.ts @@ -1,3 +1,5 @@ +import { Buffer } from "node:buffer"; + import { parseCookies } from "http/util"; import { Converter, InternalEvent, InternalResult } from "types/open-next"; @@ -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, @@ -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)) { diff --git a/packages/open-next/src/core/requestHandler.ts b/packages/open-next/src/core/requestHandler.ts index 18e82a4d..9442414d 100644 --- a/packages/open-next/src/core/requestHandler.ts +++ b/packages/open-next/src/core/requestHandler.ts @@ -33,6 +33,7 @@ export async function openNextHandler( internalEvent: internalEvent, isExternalRewrite: false, origin: false, + isISR: false, }; try { preprocessResult = await routingHandler(internalEvent); diff --git a/packages/open-next/src/core/resolve.ts b/packages/open-next/src/core/resolve.ts index a282a9f7..aac5a8a5 100644 --- a/packages/open-next/src/core/resolve.ts +++ b/packages/open-next/src/core/resolve.ts @@ -2,8 +2,10 @@ import { BaseEventOrResult, Converter, DefaultOverrideOptions, + ImageLoader, InternalEvent, InternalResult, + LazyLoadedOverride, OverrideOptions, Wrapper, } from "types/open-next.js"; @@ -88,3 +90,19 @@ export async function resolveIncrementalCache( return m_1.default; } } + +/** + * @param imageLoader + * @returns + * @__PURE__ + */ +export async function resolveImageLoader( + imageLoader: LazyLoadedOverride | string, +) { + if (typeof imageLoader === "function") { + return imageLoader(); + } else { + const m_1 = await import("../overrides/imageLoader/s3.js"); + return m_1.default; + } +} diff --git a/packages/open-next/src/core/routing/matcher.ts b/packages/open-next/src/core/routing/matcher.ts index 50e8aba5..1b7584cc 100644 --- a/packages/open-next/src/core/routing/matcher.ts +++ b/packages/open-next/src/core/routing/matcher.ts @@ -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) @@ -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, + }; } diff --git a/packages/open-next/src/core/routingHandler.ts b/packages/open-next/src/core/routingHandler.ts index da35d9f8..ca491c2d 100644 --- a/packages/open-next/src/core/routingHandler.ts +++ b/packages/open-next/src/core/routingHandler.ts @@ -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 @@ -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 && @@ -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; @@ -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), @@ -160,5 +168,6 @@ export default async function routingHandler( internalEvent, isExternalRewrite, origin: false, + isISR, }; } diff --git a/packages/open-next/src/http/openNextResponse.ts b/packages/open-next/src/http/openNextResponse.ts index 7d2615c6..f3abfba0 100644 --- a/packages/open-next/src/http/openNextResponse.ts +++ b/packages/open-next/src/http/openNextResponse.ts @@ -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); diff --git a/packages/open-next/src/overrides/imageLoader/host.ts b/packages/open-next/src/overrides/imageLoader/host.ts new file mode 100644 index 00000000..e0f28b00 --- /dev/null +++ b/packages/open-next/src/overrides/imageLoader/host.ts @@ -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); + 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; diff --git a/packages/open-next/src/overrides/imageLoader/s3.ts b/packages/open-next/src/overrides/imageLoader/s3.ts new file mode 100644 index 00000000..fcd29b2c --- /dev/null +++ b/packages/open-next/src/overrides/imageLoader/s3.ts @@ -0,0 +1,46 @@ +import { Readable } from "node:stream"; + +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { ImageLoader } from "types/open-next"; +import { FatalError } from "utils/error"; + +import { awsLogger } from "../../adapters/logger"; + +const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env; + +function ensureBucketExists() { + if (!BUCKET_NAME) { + throw new Error("Bucket name must be defined!"); + } +} + +const s3Loader: ImageLoader = { + name: "s3", + load: async (key: string) => { + const s3Client = new S3Client({ logger: awsLogger }); + + 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(/^\//, ""), + }), + ); + const body = response.Body as Readable | undefined; + + if (!body) { + throw new FatalError("No body in S3 response"); + } + + return { + body: body, + contentType: response.ContentType, + cacheControl: response.CacheControl, + }; + }, +}; + +export default s3Loader; diff --git a/packages/open-next/src/plugins/resolve.ts b/packages/open-next/src/plugins/resolve.ts index e063e92a..f654d14b 100644 --- a/packages/open-next/src/plugins/resolve.ts +++ b/packages/open-next/src/plugins/resolve.ts @@ -3,6 +3,8 @@ import { readFileSync } from "node:fs"; import { Plugin } from "esbuild"; import type { DefaultOverrideOptions, + ImageLoader, + IncludedImageLoader, LazyLoadedOverride, OverrideOptions, } from "types/open-next"; @@ -16,6 +18,7 @@ export interface IPluginSettings { tagCache?: OverrideOptions["tagCache"]; queue?: OverrideOptions["queue"]; incrementalCache?: OverrideOptions["incrementalCache"]; + imageLoader?: LazyLoadedOverride | IncludedImageLoader; }; fnName?: string; } @@ -43,6 +46,7 @@ export function openNextResolvePlugin({ logger.debug(`OpenNext Resolve plugin for ${fnName}`); build.onLoad({ filter: /core\/resolve.js/g }, async (args) => { let contents = readFileSync(args.path, "utf-8"); + //TODO: refactor this. Every override should be at the same place so we can generate this dynamically if (overrides?.wrapper) { contents = contents.replace( "../wrappers/aws-lambda.js", @@ -85,6 +89,15 @@ export function openNextResolvePlugin({ )}.js`, ); } + if (overrides?.imageLoader) { + contents = contents.replace( + "../overrides/imageLoader/s3.js", + `../overrides/imageLoader/${getOverrideOrDefault( + overrides.imageLoader, + "s3", + )}.js`, + ); + } return { contents, }; diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index b846f6cc..a58baefe 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -120,6 +120,8 @@ export type IncludedIncrementalCache = "s3" | "s3-lite"; export type IncludedTagCache = "dynamodb" | "dynamodb-lite"; +export type IncludedImageLoader = "s3" | "host"; + export interface DefaultOverrideOptions< E extends BaseEventOrResult = InternalEvent, R extends BaseEventOrResult = InternalResult, @@ -287,7 +289,11 @@ export interface OpenNextConfig { * Supports only node runtime */ imageOptimization?: DefaultFunctionOptions & { - loader?: "s3" | LazyLoadedOverride; + /** + * The image loader is used to load the image from the source. + * @default "s3" + */ + loader?: IncludedImageLoader | LazyLoadedOverride; /** * @default "arm64" */ diff --git a/packages/open-next/src/wrappers/cloudflare.ts b/packages/open-next/src/wrappers/cloudflare.ts index 96d6d77f..a9fcca44 100644 --- a/packages/open-next/src/wrappers/cloudflare.ts +++ b/packages/open-next/src/wrappers/cloudflare.ts @@ -18,7 +18,7 @@ const handler: WrapperHandler< const response = await handler(internalEvent); - const result: Response = converter.convertTo(response); + const result: Response = await converter.convertTo(response); return result; };