diff --git a/packages/open-next/src/adapters/config/index.ts b/packages/open-next/src/adapters/config/index.ts index 12adfb93..8fafbc1f 100644 --- a/packages/open-next/src/adapters/config/index.ts +++ b/packages/open-next/src/adapters/config/index.ts @@ -8,6 +8,7 @@ import { loadBuildId, loadConfig, loadConfigHeaders, + loadFunctionsConfigManifest, loadHtmlPages, loadMiddlewareManifest, loadPrerenderManifest, @@ -35,3 +36,6 @@ export const MiddlewareManifest = export const AppPathsManifest = /* @__PURE__ */ loadAppPathsManifest(NEXT_DIR); export const AppPathRoutesManifest = /* @__PURE__ */ loadAppPathRoutesManifest(NEXT_DIR); + +export const FunctionsConfigManifest = + /* @__PURE__ */ loadFunctionsConfigManifest(NEXT_DIR); diff --git a/packages/open-next/src/adapters/config/util.ts b/packages/open-next/src/adapters/config/util.ts index 501a7650..b50b5cdc 100644 --- a/packages/open-next/src/adapters/config/util.ts +++ b/packages/open-next/src/adapters/config/util.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { + FunctionsConfigManifest, MiddlewareManifest, NextConfig, PrerenderManifest, @@ -123,3 +124,17 @@ export function loadMiddlewareManifest(nextDir: string) { const json = fs.readFileSync(filePath, "utf-8"); return JSON.parse(json) as MiddlewareManifest; } + +export function loadFunctionsConfigManifest(nextDir: string) { + const filePath = path.join( + nextDir, + "server", + "functions-config-manifest.json", + ); + try { + const json = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(json) as FunctionsConfigManifest; + } catch (e) { + return; + } +} diff --git a/packages/open-next/src/build/compileConfig.ts b/packages/open-next/src/build/compileConfig.ts index bb5c2463..bc9657a3 100644 --- a/packages/open-next/src/build/compileConfig.ts +++ b/packages/open-next/src/build/compileConfig.ts @@ -50,7 +50,7 @@ export async function compileOpenNextConfig( // We need to check if the config uses the edge runtime at any point // If it does, we need to compile it with the edge runtime const usesEdgeRuntime = - config.middleware?.external || + (config.middleware?.external && config.middleware.runtime !== "node") || Object.values(config.functions || {}).some((fn) => fn.runtime === "edge"); if (!usesEdgeRuntime) { logger.debug( diff --git a/packages/open-next/src/build/copyTracedFiles.ts b/packages/open-next/src/build/copyTracedFiles.ts index eaef22c0..e667a8c1 100644 --- a/packages/open-next/src/build/copyTracedFiles.ts +++ b/packages/open-next/src/build/copyTracedFiles.ts @@ -31,6 +31,7 @@ export async function copyTracedFiles( outputDir: string, routes: string[], bundledNextServer: boolean, + skipServerFiles = false, ) { const tsStart = Date.now(); const dotNextDir = path.join(buildOutputPath, ".next"); @@ -58,10 +59,11 @@ export async function copyTracedFiles( const filesToCopy = new Map(); // Files necessary by the server - extractFiles(requiredServerFiles.files).forEach((f) => { - filesToCopy.set(f, f.replace(standaloneDir, outputDir)); - }); - + if (!skipServerFiles) { + extractFiles(requiredServerFiles.files).forEach((f) => { + filesToCopy.set(f, f.replace(standaloneDir, outputDir)); + }); + } // create directory for pages if (existsSync(path.join(standaloneDir, ".next/server/pages"))) { mkdirSync(path.join(outputNextDir, "server/pages"), { @@ -141,6 +143,15 @@ File ${fullFilePath} does not exist } }; + if (existsSync(path.join(dotNextDir, "server/middleware.js.nft.json"))) { + // We still need to copy the nft.json file so that computeCopyFilesForPage doesn't throw + copyFileSync( + path.join(dotNextDir, "server/middleware.js.nft.json"), + path.join(standaloneNextDir, "server/middleware.js.nft.json"), + ); + computeCopyFilesForPage("middleware"); + } + const hasPageDir = routes.some((route) => route.startsWith("pages/")); const hasAppDir = routes.some((route) => route.startsWith("app/")); diff --git a/packages/open-next/src/build/createMiddleware.ts b/packages/open-next/src/build/createMiddleware.ts index b6e8cdcd..5fb92354 100644 --- a/packages/open-next/src/build/createMiddleware.ts +++ b/packages/open-next/src/build/createMiddleware.ts @@ -1,14 +1,20 @@ import fs from "node:fs"; +import fsAsync from "node:fs/promises"; import path from "node:path"; import logger from "../logger.js"; import type { + FunctionsConfigManifest, MiddlewareInfo, MiddlewareManifest, } from "../types/next-types.js"; import { buildEdgeBundle } from "./edge/createEdgeBundle.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; +import { + buildBundledNodeMiddleware, + buildExternalNodeMiddleware, +} from "./middleware/buildNodeMiddleware.js"; /** * Compiles the middleware bundle. @@ -36,6 +42,33 @@ export async function createMiddleware( | MiddlewareInfo | undefined; + if (!middlewareInfo) { + // If there is no middleware info, it might be a node middleware + const functionsConfigManifestPath = path.join( + appBuildOutputPath, + ".next/server/functions-config-manifest.json", + ); + const functionsConfigManifest = JSON.parse( + await fsAsync + .readFile(functionsConfigManifestPath, "utf8") + .catch(() => "{}"), + ) as FunctionsConfigManifest; + + // TODO: Handle external node middleware in the future + if (functionsConfigManifest?.functions["/_middleware"]) { + if (!config.middleware?.external) { + // If we are here, it means that we are using a node middleware + await buildBundledNodeMiddleware(options); + // We return early to not build the edge middleware + return; + } + + // Here it means that we are using a node external middleware + await buildExternalNodeMiddleware(options); + return; + } + } + if (config.middleware?.external) { const outputPath = path.join(outputDir, "middleware"); fs.mkdirSync(outputPath, { recursive: true }); diff --git a/packages/open-next/src/build/edge/createEdgeBundle.ts b/packages/open-next/src/build/edge/createEdgeBundle.ts index a3664116..02269cc0 100644 --- a/packages/open-next/src/build/edge/createEdgeBundle.ts +++ b/packages/open-next/src/build/edge/createEdgeBundle.ts @@ -16,6 +16,7 @@ import type { import type { OriginResolver } from "types/overrides.js"; import logger from "../../logger.js"; import { openNextEdgePlugins } from "../../plugins/edge.js"; +import { openNextExternalMiddlewarePlugin } from "../../plugins/externalMiddleware.js"; import { openNextReplacementPlugin } from "../../plugins/replacement.js"; import { openNextResolvePlugin } from "../../plugins/resolve.js"; import { getCrossPlatformPathRegex } from "../../utils/regex.js"; @@ -88,14 +89,12 @@ export async function buildEdgeBundle({ target: getCrossPlatformPathRegex("adapters/middleware.js"), deletes: includeCache ? [] : ["includeCacheInMiddleware"], }), + openNextExternalMiddlewarePlugin( + path.join(options.openNextDistDir, "core", "edgeFunctionHandler.js"), + ), openNextEdgePlugins({ middlewareInfo, nextDir: path.join(options.appBuildOutputPath, ".next"), - edgeFunctionHandlerPath: path.join( - options.openNextDistDir, - "core", - "edgeFunctionHandler.js", - ), isInCloudfare, }), ], diff --git a/packages/open-next/src/build/middleware/buildNodeMiddleware.ts b/packages/open-next/src/build/middleware/buildNodeMiddleware.ts new file mode 100644 index 00000000..e9d577a0 --- /dev/null +++ b/packages/open-next/src/build/middleware/buildNodeMiddleware.ts @@ -0,0 +1,138 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { + IncludedOriginResolver, + LazyLoadedOverride, + OverrideOptions, +} from "types/open-next.js"; +import type { OriginResolver } from "types/overrides.js"; +import { getCrossPlatformPathRegex } from "utils/regex.js"; +import { openNextExternalMiddlewarePlugin } from "../../plugins/externalMiddleware.js"; +import { openNextReplacementPlugin } from "../../plugins/replacement.js"; +import { openNextResolvePlugin } from "../../plugins/resolve.js"; +import { copyTracedFiles } from "../copyTracedFiles.js"; +import * as buildHelper from "../helper.js"; +import { installDependencies } from "../installDeps.js"; + +type Override = OverrideOptions & { + originResolver?: LazyLoadedOverride | IncludedOriginResolver; +}; + +export async function buildExternalNodeMiddleware( + options: buildHelper.BuildOptions, +) { + const { appBuildOutputPath, config, outputDir } = options; + if (!config.middleware?.external) { + throw new Error( + "This function should only be called for external middleware", + ); + } + const outputPath = path.join(outputDir, "middleware"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Copy open-next.config.mjs + buildHelper.copyOpenNextConfig( + options.buildDir, + outputPath, + await buildHelper.isEdgeRuntime(config.middleware.override), + ); + const overrides = { + ...config.middleware.override, + originResolver: config.middleware.originResolver, + }; + const includeCache = config.dangerous?.enableCacheInterception; + const packagePath = buildHelper.getPackagePath(options); + + // TODO: change this so that we don't copy unnecessary files + await copyTracedFiles( + appBuildOutputPath, + packagePath, + outputPath, + [], + false, + true, + ); + + function override(target: T) { + return typeof overrides?.[target] === "string" + ? overrides[target] + : undefined; + } + + // Bundle middleware + await buildHelper.esbuildAsync( + { + entryPoints: [ + path.join(options.openNextDistDir, "adapters", "middleware.js"), + ], + outfile: path.join(outputPath, "handler.mjs"), + external: ["./.next/*"], + platform: "node", + plugins: [ + openNextResolvePlugin({ + overrides: { + wrapper: override("wrapper") ?? "aws-lambda", + converter: override("converter") ?? "aws-cloudfront", + ...(includeCache + ? { + tagCache: override("tagCache") ?? "dynamodb-lite", + incrementalCache: override("incrementalCache") ?? "s3-lite", + queue: override("queue") ?? "sqs-lite", + } + : {}), + originResolver: override("originResolver") ?? "pattern-env", + proxyExternalRequest: override("proxyExternalRequest") ?? "node", + }, + fnName: "middleware", + }), + openNextReplacementPlugin({ + name: "externalMiddlewareOverrides", + target: getCrossPlatformPathRegex("adapters/middleware.js"), + deletes: includeCache ? [] : ["includeCacheInMiddleware"], + }), + openNextExternalMiddlewarePlugin( + path.join( + options.openNextDistDir, + "core", + "nodeMiddlewareHandler.js", + ), + ), + ], + banner: { + js: [ + `globalThis.monorepoPackagePath = "${packagePath}";`, + "import process from 'node:process';", + "import { Buffer } from 'node:buffer';", + "import {AsyncLocalStorage} from 'node:async_hooks';", + "import { createRequire as topLevelCreateRequire } from 'module';", + "const require = topLevelCreateRequire(import.meta.url);", + "import bannerUrl from 'url';", + "const __dirname = bannerUrl.fileURLToPath(new URL('.', import.meta.url));", + ].join(""), + }, + }, + options, + ); + + // Do we need to copy or do something with env file here? + + installDependencies(outputPath, config.middleware?.install); +} + +export async function buildBundledNodeMiddleware( + options: buildHelper.BuildOptions, +) { + await buildHelper.esbuildAsync( + { + entryPoints: [ + path.join(options.openNextDistDir, "core", "nodeMiddlewareHandler.js"), + ], + external: ["./.next/*"], + outfile: path.join(options.buildDir, "middleware.mjs"), + bundle: true, + platform: "node", + }, + options, + ); +} diff --git a/packages/open-next/src/core/edgeFunctionHandler.ts b/packages/open-next/src/core/edgeFunctionHandler.ts index 90d21566..6667b9d4 100644 --- a/packages/open-next/src/core/edgeFunctionHandler.ts +++ b/packages/open-next/src/core/edgeFunctionHandler.ts @@ -28,6 +28,7 @@ export default async function edgeFunctionHandler( }, }, }); + // TODO: use the global waitUntil await result.waitUntil; const response = result.response; return response; diff --git a/packages/open-next/src/core/nodeMiddlewareHandler.ts b/packages/open-next/src/core/nodeMiddlewareHandler.ts new file mode 100644 index 00000000..8703edd4 --- /dev/null +++ b/packages/open-next/src/core/nodeMiddlewareHandler.ts @@ -0,0 +1,47 @@ +import type { RequestData } from "types/global"; + +type EdgeRequest = Omit; + +// Do we need Buffer here? +import { Buffer } from "node:buffer"; +globalThis.Buffer = Buffer; + +// AsyncLocalStorage is needed to be defined globally +import { AsyncLocalStorage } from "node:async_hooks"; +globalThis.AsyncLocalStorage = AsyncLocalStorage; + +interface NodeMiddleware { + default: (req: { + handler: any; + request: EdgeRequest; + page: "middleware"; + }) => Promise<{ + response: Response; + waitUntil: Promise; + }>; + middleware: any; +} + +let _module: NodeMiddleware | undefined; + +export default async function middlewareHandler( + request: EdgeRequest, +): Promise { + if (!_module) { + // We use await import here so that we are sure that it is loaded after AsyncLocalStorage is defined on globalThis + // TODO: We will probably need to change this at build time when used in a monorepo (or if the location changes) + //@ts-expect-error - This file should be bundled with esbuild + _module = (await import("./.next/server/middleware.js")).default; + } + const adapterFn = _module!.default || _module; + const result = await adapterFn({ + handler: _module!.middleware || _module, + request: request, + page: "middleware", + }); + // Not sure if we should await it here or defer to the global als + globalThis.__openNextAls + .getStore() + ?.pendingPromiseRunner.add(result.waitUntil); + return result.response; +} diff --git a/packages/open-next/src/core/routing/middleware.ts b/packages/open-next/src/core/routing/middleware.ts index 70ef37f6..424095d6 100644 --- a/packages/open-next/src/core/routing/middleware.ts +++ b/packages/open-next/src/core/routing/middleware.ts @@ -1,6 +1,10 @@ import type { ReadableStream } from "node:stream/web"; -import { MiddlewareManifest, NextConfig } from "config/index.js"; +import { + FunctionsConfigManifest, + MiddlewareManifest, + NextConfig, +} from "config/index.js"; import type { InternalEvent, InternalResult } from "types/open-next.js"; import { emptyReadableStream } from "utils/stream.js"; @@ -20,8 +24,12 @@ import { } from "./util.js"; const middlewareManifest = MiddlewareManifest; +const functionsConfigManifest = FunctionsConfigManifest; -const middleMatch = getMiddlewareMatch(middlewareManifest); +const middleMatch = getMiddlewareMatch( + middlewareManifest, + functionsConfigManifest, +); type MiddlewareEvent = InternalEvent & { responseHeaders?: Record; diff --git a/packages/open-next/src/core/routing/util.ts b/packages/open-next/src/core/routing/util.ts index 67837c99..08b5928e 100644 --- a/packages/open-next/src/core/routing/util.ts +++ b/packages/open-next/src/core/routing/util.ts @@ -6,7 +6,10 @@ import { BuildId, HtmlPages, NextConfig } from "config/index.js"; import type { IncomingMessage } from "http/index.js"; import { OpenNextNodeResponse } from "http/openNextResponse.js"; import { parseHeaders } from "http/util.js"; -import type { MiddlewareManifest } from "types/next-types"; +import type { + FunctionsConfigManifest, + MiddlewareManifest, +} from "types/next-types"; import type { InternalEvent, InternalResult, @@ -142,7 +145,17 @@ export function convertToQuery(querystring: string) { * * @__PURE__ */ -export function getMiddlewareMatch(middlewareManifest: MiddlewareManifest) { +export function getMiddlewareMatch( + middlewareManifest: MiddlewareManifest, + functionsManifest?: FunctionsConfigManifest, +) { + if (functionsManifest) { + return ( + functionsManifest.functions["/_middleware"].matchers?.map( + ({ regexp }) => new RegExp(regexp), + ) ?? [/.*/] + ); + } const rootMiddleware = middlewareManifest.middleware["/"]; if (!rootMiddleware?.matchers) return []; return rootMiddleware.matchers.map(({ regexp }) => new RegExp(regexp)); diff --git a/packages/open-next/src/plugins/edge.ts b/packages/open-next/src/plugins/edge.ts index 284b8aa3..4569a3a8 100644 --- a/packages/open-next/src/plugins/edge.ts +++ b/packages/open-next/src/plugins/edge.ts @@ -22,21 +22,18 @@ import { getCrossPlatformPathRegex } from "../utils/regex.js"; export interface IPluginSettings { nextDir: string; - edgeFunctionHandlerPath?: string; middlewareInfo?: MiddlewareInfo; isInCloudfare?: boolean; } /** * @param opts.nextDir - The path to the .next directory - * @param opts.edgeFunctionHandlerPath - The path to the edgeFunctionHandler.js file that we'll use to bundle the routing * @param opts.middlewareInfo - Information about the middleware * @param opts.isInCloudfare - Whether the code runs on the cloudflare runtime * @returns */ export function openNextEdgePlugins({ nextDir, - edgeFunctionHandlerPath, middlewareInfo, isInCloudfare, }: IPluginSettings): Plugin { @@ -57,17 +54,6 @@ export function openNextEdgePlugins({ name: "opennext-edge", setup(build) { logger.debug(chalk.blue("OpenNext Edge plugin")); - if (edgeFunctionHandlerPath) { - // If we bundle the routing, we need to resolve the middleware - build.onResolve( - { filter: getCrossPlatformPathRegex("./middleware.mjs") }, - () => { - return { - path: edgeFunctionHandlerPath, - }; - }, - ); - } build.onResolve({ filter: /\.(mjs|wasm)$/g }, () => { return { diff --git a/packages/open-next/src/plugins/externalMiddleware.ts b/packages/open-next/src/plugins/externalMiddleware.ts new file mode 100644 index 00000000..f2b75ec3 --- /dev/null +++ b/packages/open-next/src/plugins/externalMiddleware.ts @@ -0,0 +1,19 @@ +import type { Plugin } from "esbuild"; +import { getCrossPlatformPathRegex } from "utils/regex.js"; + +export function openNextExternalMiddlewarePlugin(functionPath: string): Plugin { + return { + name: "open-next-external-node-middleware", + setup(build) { + // If we bundle the routing, we need to resolve the middleware + build.onResolve( + { filter: getCrossPlatformPathRegex("./middleware.mjs") }, + () => { + return { + path: functionPath, + }; + }, + ); + }, + }; +} diff --git a/packages/open-next/src/types/next-types.ts b/packages/open-next/src/types/next-types.ts index 1608dda6..4d0803e0 100644 --- a/packages/open-next/src/types/next-types.ts +++ b/packages/open-next/src/types/next-types.ts @@ -178,3 +178,20 @@ export type PluginHandler = ( res: OpenNextNodeResponse, options: Options, ) => Promise; + +export interface FunctionsConfigManifest { + version: number; + functions: Record< + string, + { + maxDuration?: number | undefined; + runtime?: "nodejs"; + matchers?: Array<{ + regexp: string; + originalSource: string; + has?: Rewrite["has"]; + missing?: Rewrite["has"]; + }>; + } + >; +} diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index 114f92f7..59ba6c55 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -345,6 +345,12 @@ export interface OpenNextConfig { //We force the middleware to be a function external: true; + /** + * The runtime used by next for the middleware. + * @default "edge" + */ + runtime?: "node" | "edge"; + /** * The override options for the middleware. * By default the lite override are used (.i.e. s3-lite, dynamodb-lite, sqs-lite) diff --git a/packages/tests-unit/tests/core/routing/middleware.test.ts b/packages/tests-unit/tests/core/routing/middleware.test.ts index 541c5757..6683a028 100644 --- a/packages/tests-unit/tests/core/routing/middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/middleware.test.ts @@ -35,6 +35,7 @@ vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({ functions: {}, version: 2, }, + FunctionsConfigManifest: undefined, })); vi.mock("@opennextjs/aws/core/routing/i18n/index.js", () => ({