diff --git a/.changeset/strong-keys-ring.md b/.changeset/strong-keys-ring.md new file mode 100644 index 000000000..98b70d0cb --- /dev/null +++ b/.changeset/strong-keys-ring.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +Feat: Allow overriding the proxying for external rewrite diff --git a/packages/open-next/src/adapters/middleware.ts b/packages/open-next/src/adapters/middleware.ts index 5ae669518..952d58fa7 100644 --- a/packages/open-next/src/adapters/middleware.ts +++ b/packages/open-next/src/adapters/middleware.ts @@ -1,11 +1,12 @@ import type { InternalEvent, Origin } from "types/open-next"; import { runWithOpenNextRequestContext } from "utils/promise"; -import { debug } from "../adapters/logger"; +import { debug, error } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; import { resolveIncrementalCache, resolveOriginResolver, + resolveProxyRequest, resolveQueue, resolveTagCache, } from "../core/resolve"; @@ -19,6 +20,10 @@ const defaultHandler = async (internalEvent: InternalEvent) => { globalThis.openNextConfig.middleware?.originResolver, ); + const externalRequestProxy = await resolveProxyRequest( + globalThis.openNextConfig.middleware?.override?.proxyExternalRequest, + ); + //#override includeCacheInMiddleware globalThis.tagCache = await resolveTagCache( globalThis.openNextConfig.middleware?.override?.tagCache, @@ -40,17 +45,36 @@ const defaultHandler = async (internalEvent: InternalEvent) => { const result = await routingHandler(internalEvent); if ("internalEvent" in result) { debug("Middleware intercepted event", internalEvent); - let origin: Origin | false = false; if (!result.isExternalRewrite) { - origin = await originResolver.resolve(result.internalEvent.rawPath); + const origin = await originResolver.resolve( + result.internalEvent.rawPath, + ); + return { + type: "middleware", + internalEvent: result.internalEvent, + isExternalRewrite: result.isExternalRewrite, + origin, + isISR: result.isISR, + }; + } + try { + return externalRequestProxy.proxy(result.internalEvent); + } catch (e) { + error("External request failed.", e); + return { + type: "middleware", + internalEvent: { + ...result.internalEvent, + rawPath: "/500", + url: "/500", + method: "GET", + }, + // On error we need to rewrite to the 500 page which is an internal rewrite + isExternalRewrite: false, + origin: false, + isISR: result.isISR, + }; } - return { - type: "middleware", - internalEvent: result.internalEvent, - isExternalRewrite: result.isExternalRewrite, - origin, - isISR: result.isISR, - }; } debug("Middleware response", result); diff --git a/packages/open-next/src/build/createMiddleware.ts b/packages/open-next/src/build/createMiddleware.ts index f74b8ac23..13a957ce0 100644 --- a/packages/open-next/src/build/createMiddleware.ts +++ b/packages/open-next/src/build/createMiddleware.ts @@ -57,7 +57,10 @@ export async function createMiddleware( outfile: path.join(outputPath, "handler.mjs"), middlewareInfo, options, - overrides: config.middleware?.override, + overrides: { + ...config.middleware.override, + originResolver: config.middleware.originResolver, + }, defaultConverter: "aws-cloudfront", includeCache: config.dangerous?.enableCacheInterception, additionalExternals: config.edgeExternals, diff --git a/packages/open-next/src/build/edge/createEdgeBundle.ts b/packages/open-next/src/build/edge/createEdgeBundle.ts index 66a796db3..ce8d785c1 100644 --- a/packages/open-next/src/build/edge/createEdgeBundle.ts +++ b/packages/open-next/src/build/edge/createEdgeBundle.ts @@ -6,11 +6,14 @@ import { build } from "esbuild"; import type { MiddlewareInfo, MiddlewareManifest } from "types/next-types"; import type { IncludedConverter, + IncludedOriginResolver, + LazyLoadedOverride, OverrideOptions, RouteTemplate, SplittedFunctionOptions, } from "types/open-next"; +import type { OriginResolver } from "types/overrides.js"; import logger from "../../logger.js"; import { openNextEdgePlugins } from "../../plugins/edge.js"; import { openNextReplacementPlugin } from "../../plugins/replacement.js"; @@ -23,7 +26,11 @@ interface BuildEdgeBundleOptions { entrypoint: string; outfile: string; options: BuildOptions; - overrides?: OverrideOptions; + overrides?: OverrideOptions & { + originResolver?: + | LazyLoadedOverride + | IncludedOriginResolver; + }; defaultConverter?: IncludedConverter; additionalInject?: string; includeCache?: boolean; @@ -84,6 +91,14 @@ export async function buildEdgeBundle({ : "sqs-lite", } : {}), + originResolver: + typeof overrides?.originResolver === "string" + ? overrides.originResolver + : "pattern-env", + proxyExternalRequest: + typeof overrides?.proxyExternalRequest === "string" + ? overrides.proxyExternalRequest + : "node", }, fnName: name, }), diff --git a/packages/open-next/src/build/validateConfig.ts b/packages/open-next/src/build/validateConfig.ts index e7f84728b..451f116b8 100644 --- a/packages/open-next/src/build/validateConfig.ts +++ b/packages/open-next/src/build/validateConfig.ts @@ -18,6 +18,7 @@ const compatibilityMatrix: Record = { "aws-lambda-streaming": ["aws-apigw-v2"], cloudflare: ["edge"], node: ["node"], + dummy: [], }; function validateFunctionOptions(fnOptions: FunctionOptions) { diff --git a/packages/open-next/src/core/createMainHandler.ts b/packages/open-next/src/core/createMainHandler.ts index 3c96d7f7a..72d26f47c 100644 --- a/packages/open-next/src/core/createMainHandler.ts +++ b/packages/open-next/src/core/createMainHandler.ts @@ -6,6 +6,7 @@ import { openNextHandler } from "./requestHandler"; import { resolveConverter, resolveIncrementalCache, + resolveProxyRequest, resolveQueue, resolveTagCache, resolveWrapper, @@ -33,6 +34,10 @@ export async function createMainHandler() { globalThis.tagCache = await resolveTagCache(thisFunction.override?.tagCache); + globalThis.proxyExternalRequest = await resolveProxyRequest( + thisFunction.override?.proxyExternalRequest, + ); + globalThis.lastModified = {}; // From the config, we create the converter diff --git a/packages/open-next/src/core/requestHandler.ts b/packages/open-next/src/core/requestHandler.ts index ffce10383..1030581cf 100644 --- a/packages/open-next/src/core/requestHandler.ts +++ b/packages/open-next/src/core/requestHandler.ts @@ -7,7 +7,8 @@ import { runWithOpenNextRequestContext } from "utils/promise"; import { debug, error, warn } from "../adapters/logger"; import { patchAsyncStorage } from "./patchAsyncStorage"; -import { convertRes, createServerResponse, proxyRequest } from "./routing/util"; +import { resolveProxyRequest } from "./resolve"; +import { convertRes, createServerResponse } from "./routing/util"; import type { MiddlewareOutputEvent } from "./routingHandler"; import routingHandler, { MIDDLEWARE_HEADER_PREFIX, @@ -65,6 +66,35 @@ export async function openNextHandler( delete headers[rawKey]; } + if ( + "isExternalRewrite" in preprocessResult && + preprocessResult.isExternalRewrite === true + ) { + try { + preprocessResult = await globalThis.proxyExternalRequest.proxy( + preprocessResult.internalEvent, + ); + } catch (e) { + error("External request failed.", e); + preprocessResult = { + internalEvent: { + type: "core", + rawPath: "/500", + method: "GET", + headers: {}, + url: "/500", + query: {}, + cookies: {}, + remoteAddress: "", + }, + // On error we need to rewrite to the 500 page which is an internal rewrite + isExternalRewrite: false, + isISR: false, + origin: false, + }; + } + } + if ("type" in preprocessResult) { // response is used only in the streaming case if (responseStreaming) { @@ -110,7 +140,6 @@ export async function openNextHandler( store.mergeHeadersPriority = mergeHeadersPriority; } - const preprocessedResult = preprocessResult as MiddlewareOutputEvent; const req = new IncomingMessage(reqProps); const res = createServerResponse( preprocessedEvent, @@ -118,12 +147,7 @@ export async function openNextHandler( responseStreaming, ); - await processRequest( - req, - res, - preprocessedEvent, - preprocessedResult.isExternalRewrite, - ); + await processRequest(req, res, preprocessedEvent); const { statusCode, @@ -155,7 +179,6 @@ async function processRequest( req: IncomingMessage, res: OpenNextNodeResponse, internalEvent: InternalEvent, - isExternalRewrite?: boolean, ) { // @ts-ignore // Next.js doesn't parse body if the property exists @@ -163,13 +186,6 @@ async function processRequest( delete req.body; try { - // `serverHandler` is replaced at build time depending on user's - // nextjs version to patch Nextjs 13.4.x and future breaking changes. - - if (isExternalRewrite) { - return proxyRequest(internalEvent, res); - } - //#override applyNextjsPrebundledReact setNextjsPrebundledReact(internalEvent.rawPath); //#endOverride diff --git a/packages/open-next/src/core/resolve.ts b/packages/open-next/src/core/resolve.ts index d31edfc30..21970e9ed 100644 --- a/packages/open-next/src/core/resolve.ts +++ b/packages/open-next/src/core/resolve.ts @@ -3,17 +3,13 @@ import type { DefaultOverrideOptions, InternalEvent, InternalResult, - LazyLoadedOverride, + OpenNextConfig, OverrideOptions, } from "types/open-next"; -import type { - Converter, - ImageLoader, - OriginResolver, - TagCache, - Warmer, - Wrapper, -} from "types/overrides"; +import type { Converter, TagCache, Wrapper } from "types/overrides"; + +// Just a little utility type to remove undefined from a type +type RemoveUndefined = T extends undefined ? never : T; export async function resolveConverter< E extends BaseEventOrResult = InternalEvent, @@ -95,7 +91,7 @@ export async function resolveIncrementalCache( * @__PURE__ */ export async function resolveImageLoader( - imageLoader: LazyLoadedOverride | string, + imageLoader: RemoveUndefined["loader"], ) { if (typeof imageLoader === "function") { return imageLoader(); @@ -109,7 +105,9 @@ export async function resolveImageLoader( * @__PURE__ */ export async function resolveOriginResolver( - originResolver?: LazyLoadedOverride | string, + originResolver: RemoveUndefined< + OpenNextConfig["middleware"] + >["originResolver"], ) { if (typeof originResolver === "function") { return originResolver(); @@ -122,7 +120,7 @@ export async function resolveOriginResolver( * @__PURE__ */ export async function resolveWarmerInvoke( - warmer?: LazyLoadedOverride | "aws-lambda", + warmer: RemoveUndefined["invokeFunction"], ) { if (typeof warmer === "function") { return warmer(); @@ -130,3 +128,16 @@ export async function resolveWarmerInvoke( const m_1 = await import("../overrides/warmer/aws-lambda.js"); return m_1.default; } + +/** + * @__PURE__ + */ +export async function resolveProxyRequest( + proxyRequest: OverrideOptions["proxyExternalRequest"], +) { + if (typeof proxyRequest === "function") { + return proxyRequest(); + } + const m_1 = await import("../overrides/proxyExternalRequest/node.js"); + return m_1.default; +} diff --git a/packages/open-next/src/core/routing/util.ts b/packages/open-next/src/core/routing/util.ts index db5a9ba5c..cba325f17 100644 --- a/packages/open-next/src/core/routing/util.ts +++ b/packages/open-next/src/core/routing/util.ts @@ -164,33 +164,6 @@ export function unescapeRegex(str: string) { .replaceAll("_ยต3_", "(...)"); } -/** - * - * @__PURE__ - */ -function filterHeadersForProxy( - headers: Record, -) { - const filteredHeaders: Record = {}; - const disallowedHeaders = [ - "host", - "connection", - "via", - "x-cache", - "transfer-encoding", - "content-encoding", - "content-length", - ]; - Object.entries(headers).forEach(([key, value]) => { - const lowerKey = key.toLowerCase(); - if (disallowedHeaders.includes(lowerKey) || lowerKey.startsWith("x-amz")) - return; - - filteredHeaders[key] = value?.toString() ?? ""; - }); - return filteredHeaders; -} - /** * @__PURE__ */ @@ -209,61 +182,6 @@ export function convertBodyToReadableStream( return readable; } -/** - * - * @__PURE__ - */ -export async function proxyRequest( - internalEvent: InternalEvent, - res: OpenNextNodeResponse, -) { - const { url, headers, method, body } = internalEvent; - const request = await import("node:https").then((m) => m.request); - debug("proxyRequest", url); - await new Promise((resolve, reject) => { - const filteredHeaders = filterHeadersForProxy(headers); - debug("filteredHeaders", filteredHeaders); - const req = request( - url, - { - headers: filteredHeaders, - method, - rejectUnauthorized: false, - }, - (_res) => { - res.writeHead( - _res.statusCode ?? 200, - filterHeadersForProxy(_res.headers), - ); - if (_res.headers["content-encoding"] === "br") { - _res.pipe(require("node:zlib").createBrotliDecompress()).pipe(res); - } else if (_res.headers["content-encoding"] === "gzip") { - _res.pipe(require("node:zlib").createGunzip()).pipe(res); - } else { - _res.pipe(res); - } - - _res.on("error", (e) => { - error("proxyRequest error", e); - res.end(); - reject(e); - }); - res.on("finish", () => { - resolve(); - }); - }, - ); - - if (body && method !== "GET" && method !== "HEAD") { - req.write(body); - } - req.end(); - }); - // console.log("result", result); - // res.writeHead(result.status, resHeaders); - // res.end(await result.text()); -} - enum CommonHeaders { CACHE_CONTROL = "cache-control", NEXT_CACHE = "x-nextjs-cache", diff --git a/packages/open-next/src/overrides/converters/aws-cloudfront.ts b/packages/open-next/src/overrides/converters/aws-cloudfront.ts index 3d60e53f6..494cd84d0 100644 --- a/packages/open-next/src/overrides/converters/aws-cloudfront.ts +++ b/packages/open-next/src/overrides/converters/aws-cloudfront.ts @@ -18,7 +18,6 @@ import { convertToQuery, convertToQueryString, createServerResponse, - proxyRequest, } from "../../core/routing/util"; import type { MiddlewareOutputEvent } from "../../core/routingHandler"; @@ -159,26 +158,7 @@ async function convertToCloudFrontRequestResult( const responseHeaders = result.internalEvent.headers; // Handle external rewrite - if (result.isExternalRewrite) { - const serverResponse = createServerResponse(result.internalEvent, {}); - await proxyRequest(result.internalEvent, serverResponse); - const externalResult = convertRes(serverResponse); - const body = await fromReadableStream( - externalResult.body, - externalResult.isBase64Encoded, - ); - const cloudfrontResult = { - status: externalResult.statusCode.toString(), - statusDescription: "OK", - headers: convertToCloudfrontHeaders(externalResult.headers, true), - bodyEncoding: externalResult.isBase64Encoded - ? ("base64" as const) - : ("text" as const), - body, - }; - debug("externalResult", cloudfrontResult); - return cloudfrontResult; - } + let customOrigin = origin?.custom as CloudFrontCustomOrigin; let host = responseHeaders.host ?? responseHeaders.Host; if (result.origin) { diff --git a/packages/open-next/src/overrides/proxyExternalRequest/dummy.ts b/packages/open-next/src/overrides/proxyExternalRequest/dummy.ts new file mode 100644 index 000000000..0ef1b51d8 --- /dev/null +++ b/packages/open-next/src/overrides/proxyExternalRequest/dummy.ts @@ -0,0 +1,10 @@ +import type { ProxyExternalRequest } from "types/overrides"; + +const DummyProxyExternalRequest: ProxyExternalRequest = { + name: "dummy", + proxy: async (_event) => { + throw new Error("This is a dummy implementation"); + }, +}; + +export default DummyProxyExternalRequest; diff --git a/packages/open-next/src/overrides/proxyExternalRequest/fetch.ts b/packages/open-next/src/overrides/proxyExternalRequest/fetch.ts new file mode 100644 index 000000000..c10234dab --- /dev/null +++ b/packages/open-next/src/overrides/proxyExternalRequest/fetch.ts @@ -0,0 +1,28 @@ +import type { ProxyExternalRequest } from "types/overrides"; +import { emptyReadableStream } from "utils/stream"; + +const fetchProxy: ProxyExternalRequest = { + name: "fetch-proxy", + // @ts-ignore + proxy: async (internalEvent) => { + const { url, headers, method, body } = internalEvent; + const response = await fetch(url, { + method, + headers, + body, + }); + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + return { + type: "core", + headers: responseHeaders, + statusCode: response.status, + isBase64Encoded: true, + body: response.body ?? emptyReadableStream(), + }; + }, +}; + +export default fetchProxy; diff --git a/packages/open-next/src/overrides/proxyExternalRequest/node.ts b/packages/open-next/src/overrides/proxyExternalRequest/node.ts new file mode 100644 index 000000000..5ab0f4659 --- /dev/null +++ b/packages/open-next/src/overrides/proxyExternalRequest/node.ts @@ -0,0 +1,86 @@ +import { debug, error } from "node:console"; +import { request } from "node:https"; +import { Readable } from "node:stream"; +import type { InternalEvent, InternalResult } from "types/open-next"; +import type { ProxyExternalRequest } from "types/overrides"; +import { isBinaryContentType } from "../../adapters/binary"; + +function filterHeadersForProxy( + headers: Record, +) { + const filteredHeaders: Record = {}; + const disallowedHeaders = [ + "host", + "connection", + "via", + "x-cache", + "transfer-encoding", + "content-encoding", + "content-length", + ]; + Object.entries(headers) + .filter(([key, _]) => { + const lowerKey = key.toLowerCase(); + return !( + disallowedHeaders.includes(lowerKey) || lowerKey.startsWith("x-amz") + ); + }) + .forEach(([key, value]) => { + filteredHeaders[key] = value?.toString() ?? ""; + }); + return filteredHeaders; +} + +const nodeProxy: ProxyExternalRequest = { + name: "node-proxy", + proxy: (internalEvent: InternalEvent) => { + const { url, headers, method, body } = internalEvent; + debug("proxyRequest", url); + return new Promise((resolve, reject) => { + const filteredHeaders = filterHeadersForProxy(headers); + debug("filteredHeaders", filteredHeaders); + const req = request( + url, + { + headers: filteredHeaders, + method, + rejectUnauthorized: false, + }, + (_res) => { + const nodeReadableStream = + _res.headers["content-encoding"] === "br" + ? _res.pipe(require("node:zlib").createBrotliDecompress()) + : _res.headers["content-encoding"] === "gzip" + ? _res.pipe(require("node:zlib").createGunzip()) + : _res; + + const isBase64Encoded = + isBinaryContentType(headers["content-type"]) || + !!headers["content-encoding"]; + const result: InternalResult = { + type: "core", + headers: filterHeadersForProxy(_res.headers), + statusCode: _res.statusCode ?? 200, + // TODO: check base64 encoding + isBase64Encoded, + body: Readable.toWeb(nodeReadableStream), + }; + + resolve(result); + + _res.on("error", (e) => { + error("proxyRequest error", e); + reject(e); + }); + }, + ); + + if (body && method !== "GET" && method !== "HEAD") { + req.write(body); + } + req.end(); + }); + }, +}; + +export default nodeProxy; diff --git a/packages/open-next/src/plugins/resolve.ts b/packages/open-next/src/plugins/resolve.ts index a0ea01cc9..aa0b9d35b 100644 --- a/packages/open-next/src/plugins/resolve.ts +++ b/packages/open-next/src/plugins/resolve.ts @@ -26,6 +26,7 @@ export interface IPluginSettings { | LazyLoadedOverride | IncludedOriginResolver; warmer?: LazyLoadedOverride | IncludedWarmer; + proxyExternalRequest?: OverrideOptions["proxyExternalRequest"]; }; fnName?: string; } @@ -50,6 +51,7 @@ const nameToFolder = { imageLoader: "imageLoader", originResolver: "originResolver", warmer: "warmer", + proxyExternalRequest: "proxyExternalRequest", }; const defaultOverrides = { @@ -61,6 +63,7 @@ const defaultOverrides = { imageLoader: "s3", originResolver: "pattern-env", warmer: "aws-lambda", + proxyExternalRequest: "node", }; /** diff --git a/packages/open-next/src/types/global.ts b/packages/open-next/src/types/global.ts index 2f9191a31..033802a50 100644 --- a/packages/open-next/src/types/global.ts +++ b/packages/open-next/src/types/global.ts @@ -1,7 +1,12 @@ import type { AsyncLocalStorage } from "node:async_hooks"; import type { OutgoingHttpHeaders } from "node:http"; -import type { IncrementalCache, Queue, TagCache } from "types/overrides"; +import type { + IncrementalCache, + ProxyExternalRequest, + Queue, + TagCache, +} from "types/overrides"; import type { DetachedPromiseRunner } from "../utils/promise"; import type { OpenNextConfig } from "./open-next"; @@ -194,4 +199,11 @@ declare global { * Defined in `createMainHandler` or in `adapter/middleware.ts`. */ var queue: Queue; + + /** + * The function that is used when resolving external rewrite requests. + * Only available in main functions + * Defined in `createMainHandler`. + */ + var proxyExternalRequest: ProxyExternalRequest; } diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index bd9755843..2138a3819 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -6,6 +6,7 @@ import type { ImageLoader, IncrementalCache, OriginResolver, + ProxyExternalRequest, Queue, TagCache, Warmer, @@ -79,7 +80,8 @@ export type IncludedWrapper = | "aws-lambda" | "aws-lambda-streaming" | "node" - | "cloudflare"; + | "cloudflare" + | "dummy"; export type IncludedConverter = | "aws-apigw-v2" @@ -90,17 +92,19 @@ export type IncludedConverter = | "sqs-revalidate" | "dummy"; -export type IncludedQueue = "sqs" | "sqs-lite"; +export type IncludedQueue = "sqs" | "sqs-lite" | "dummy"; + +export type IncludedIncrementalCache = "s3" | "s3-lite" | "dummy"; -export type IncludedIncrementalCache = "s3" | "s3-lite"; +export type IncludedTagCache = "dynamodb" | "dynamodb-lite" | "dummy"; -export type IncludedTagCache = "dynamodb" | "dynamodb-lite"; +export type IncludedImageLoader = "s3" | "host" | "dummy"; -export type IncludedImageLoader = "s3" | "host"; +export type IncludedOriginResolver = "pattern-env" | "dummy"; -export type IncludedOriginResolver = "pattern-env"; +export type IncludedWarmer = "aws-lambda" | "dummy"; -export type IncludedWarmer = "aws-lambda"; +export type IncludedProxyExternalRequest = "node" | "fetch" | "dummy"; export interface DefaultOverrideOptions< E extends BaseEventOrResult = InternalEvent, @@ -145,6 +149,14 @@ export interface OverrideOptions extends DefaultOverrideOptions { * @default "sqs" */ queue?: IncludedQueue | LazyLoadedOverride; + + /** + * Add possibility to override the default proxy for external rewrite + * @default "node" + */ + proxyExternalRequest?: + | IncludedProxyExternalRequest + | LazyLoadedOverride; } export interface InstallOptions { @@ -297,7 +309,7 @@ export interface OpenNextConfig { * @default undefined */ warmer?: DefaultFunctionOptions & { - invokeFunction: IncludedWarmer | LazyLoadedOverride; + invokeFunction?: IncludedWarmer | LazyLoadedOverride; }; /** diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 74bc171db..b245b7b2e 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -135,3 +135,7 @@ export type ImageLoader = BaseOverride & { export type OriginResolver = BaseOverride & { resolve: (path: string) => Promise; }; + +export type ProxyExternalRequest = BaseOverride & { + proxy: (event: InternalEvent) => Promise; +};