diff --git a/packages/open-next/src/build.ts b/packages/open-next/src/build.ts index e6db15917..c5dc9870a 100755 --- a/packages/open-next/src/build.ts +++ b/packages/open-next/src/build.ts @@ -1,40 +1,21 @@ -import cp from "node:child_process"; -import fs from "node:fs"; -import { createRequire as topLevelCreateRequire } from "node:module"; -import os from "node:os"; -import path from "node:path"; import url from "node:url"; -import { MiddlewareManifest } from "types/next-types.js"; - -import { isBinaryContentType } from "./adapters/binary.js"; import { - compileOpenNextConfigEdge, - compileOpenNextConfigNode, -} from "./build/compileConfig.js"; + buildNextjsApp, + setStandaloneBuildMode, +} from "./build/buildNextApp.js"; +import { compileCache } from "./build/compileCache.js"; +import { compileOpenNextConfig } from "./build/compileConfig.js"; +import { createCacheAssets, createStaticAssets } from "./build/createAssets.js"; +import { createImageOptimizationBundle } from "./build/createImageOptimizationBundle.js"; +import { createMiddleware } from "./build/createMiddleware.js"; +import { createRevalidationBundle } from "./build/createRevalidationBundle.js"; import { createServerBundle } from "./build/createServerBundle.js"; -import { buildEdgeBundle } from "./build/edge/createEdgeBundle.js"; +import { createWarmerBundle } from "./build/createWarmerBundle.js"; import { generateOutput } from "./build/generateOutput.js"; -import { - BuildOptions, - compareSemver, - copyOpenNextConfig, - esbuildAsync, - esbuildSync, - getBuildId, - getHtmlPages, - normalizeOptions, - removeFiles, - traverseFiles, -} from "./build/helper.js"; -import { validateConfig } from "./build/validateConfig.js"; +import * as buildHelper from "./build/helper.js"; +import { printHeader, showWarningOnWindows } from "./build/utils.js"; import logger from "./logger.js"; -import { openNextReplacementPlugin } from "./plugins/replacement.js"; -import { openNextResolvePlugin } from "./plugins/resolve.js"; -import { OpenNextConfig } from "./types/open-next.js"; - -const require = topLevelCreateRequire(import.meta.url); -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); export type PublicFiles = { files: string[]; @@ -44,36 +25,29 @@ export async function build( openNextConfigPath?: string, nodeExternals?: string, ) { - showWindowsWarning(); + showWarningOnWindows(); + + const baseDir = process.cwd(); + const openNextDistDir = url.fileURLToPath(new URL(".", import.meta.url)); - // Load open-next.config.ts - const tempBuildDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-next-tmp")); - let configPath = compileOpenNextConfigNode( - tempBuildDir, + const { config, buildDir } = await compileOpenNextConfig( + baseDir, openNextConfigPath, nodeExternals, ); - // On Windows, we need to use file:// protocol to load the config file using import() - if (process.platform === "win32") configPath = `file://${configPath}`; - const config = (await import(configPath)).default as OpenNextConfig; - if (!config || !config.default) { - logger.error( - `config.default cannot be empty, it should be at least {}, see more info here: https://open-next.js.org/config#configuration-file`, - ); - process.exit(1); - } - validateConfig(config); - - compileOpenNextConfigEdge(tempBuildDir, config, openNextConfigPath); // Initialize options - const options = normalizeOptions(config, tempBuildDir); + const options = buildHelper.normalizeOptions( + config, + openNextDistDir, + buildDir, + ); logger.setLevel(options.debug ? "debug" : "info"); // Pre-build validation - checkRunningInsideNextjsApp(options); - printNextjsVersion(options); - printOpenNextVersion(options); + buildHelper.checkRunningInsideNextjsApp(options); + buildHelper.printNextjsVersion(options); + buildHelper.printOpenNextVersion(options); // Build Next.js app printHeader("Building Next.js app"); @@ -82,7 +56,7 @@ export async function build( // Generate deployable bundle printHeader("Generating bundle"); - initOutputDir(options); + buildHelper.initOutputDir(options); // Compile cache.ts compileCache(options); @@ -100,624 +74,3 @@ export async function build( await generateOutput(options); logger.info("OpenNext build complete."); } - -function showWindowsWarning() { - if (os.platform() !== "win32") return; - - logger.warn("OpenNext is not fully compatible with Windows."); - logger.warn( - "For optimal performance, it is recommended to use Windows Subsystem for Linux (WSL).", - ); - logger.warn( - "While OpenNext may function on Windows, it could encounter unpredictable failures during runtime.", - ); -} - -function checkRunningInsideNextjsApp(options: BuildOptions) { - const { appPath } = options; - const extension = ["js", "cjs", "mjs", "ts"].find((ext) => - fs.existsSync(path.join(appPath, `next.config.${ext}`)), - ); - if (!extension) { - logger.error( - "Error: next.config.js not found. Please make sure you are running this command inside a Next.js app.", - ); - process.exit(1); - } -} - -function setStandaloneBuildMode(options: BuildOptions) { - // Equivalent to setting `output: "standalone"` in next.config.js - process.env.NEXT_PRIVATE_STANDALONE = "true"; - // Equivalent to setting `experimental.outputFileTracingRoot` in next.config.js - process.env.NEXT_PRIVATE_OUTPUT_TRACE_ROOT = options.monorepoRoot; -} - -function buildNextjsApp(options: BuildOptions) { - const { config, packager } = options; - const command = - config.buildCommand ?? - (["bun", "npm"].includes(packager) - ? `${packager} run build` - : `${packager} build`); - cp.execSync(command, { - stdio: "inherit", - cwd: path.dirname(options.appPackageJsonPath), - }); -} - -function printHeader(header: string) { - header = `OpenNext — ${header}`; - logger.info( - [ - "", - "┌" + "─".repeat(header.length + 2) + "┐", - `│ ${header} │`, - "└" + "─".repeat(header.length + 2) + "┘", - "", - ].join("\n"), - ); -} - -function printNextjsVersion(options: BuildOptions) { - logger.info(`Next.js version : ${options.nextVersion}`); -} - -function printOpenNextVersion(options: BuildOptions) { - logger.info(`OpenNext v${options.openNextVersion}`); -} - -function initOutputDir(options: BuildOptions) { - // We need to get the build relative to the cwd to find the compiled config - // This is needed for the case where the app is a single-version monorepo and the package.json is in the root of the monorepo - // where the build is in the app directory, but the compiled config is in the root of the monorepo. - fs.rmSync(options.outputDir, { recursive: true, force: true }); - const { buildDir } = options; - fs.mkdirSync(buildDir, { recursive: true }); - fs.cpSync(options.tempBuildDir, buildDir, { recursive: true }); -} - -async function createWarmerBundle(options: BuildOptions) { - logger.info(`Bundling warmer function...`); - - const { config, outputDir } = options; - - // Create output folder - const outputPath = path.join(outputDir, "warmer-function"); - fs.mkdirSync(outputPath, { recursive: true }); - - // Copy open-next.config.mjs into the bundle - copyOpenNextConfig(options.buildDir, outputPath); - - // Build Lambda code - // note: bundle in OpenNext package b/c the adatper relys on the - // "serverless-http" package which is not a dependency in user's - // Next.js app. - await esbuildAsync( - { - entryPoints: [path.join(__dirname, "adapters", "warmer-function.js")], - external: ["next"], - outfile: path.join(outputPath, "index.mjs"), - plugins: [ - openNextResolvePlugin({ - overrides: { - converter: config.warmer?.override?.converter ?? "dummy", - wrapper: config.warmer?.override?.wrapper, - }, - fnName: "warmer", - }), - ], - banner: { - js: [ - "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, - ); -} - -async function createRevalidationBundle(options: BuildOptions) { - logger.info(`Bundling revalidation function...`); - - const { appBuildOutputPath, config, outputDir } = options; - - // Create output folder - const outputPath = path.join(outputDir, "revalidation-function"); - fs.mkdirSync(outputPath, { recursive: true }); - - //Copy open-next.config.mjs into the bundle - copyOpenNextConfig(options.buildDir, outputPath); - - // Build Lambda code - await esbuildAsync( - { - external: ["next", "styled-jsx", "react"], - entryPoints: [path.join(__dirname, "adapters", "revalidate.js")], - outfile: path.join(outputPath, "index.mjs"), - plugins: [ - openNextResolvePlugin({ - fnName: "revalidate", - overrides: { - converter: - config.revalidate?.override?.converter ?? "sqs-revalidate", - wrapper: config.revalidate?.override?.wrapper, - }, - }), - ], - }, - options, - ); - - // Copy over .next/prerender-manifest.json file - fs.copyFileSync( - path.join(appBuildOutputPath, ".next", "prerender-manifest.json"), - path.join(outputPath, "prerender-manifest.json"), - ); -} - -async function createImageOptimizationBundle(options: BuildOptions) { - logger.info(`Bundling image optimization function...`); - - const { appPath, appBuildOutputPath, config, outputDir } = options; - - // Create output folder - const outputPath = path.join(outputDir, "image-optimization-function"); - fs.mkdirSync(outputPath, { recursive: true }); - - // Copy open-next.config.mjs into the bundle - copyOpenNextConfig(options.buildDir, outputPath); - - const plugins = [ - openNextResolvePlugin({ - fnName: "imageOptimization", - overrides: { - converter: config.imageOptimization?.override?.converter, - wrapper: config.imageOptimization?.override?.wrapper, - imageLoader: config.imageOptimization?.loader, - }, - }), - ]; - - if (compareSemver(options.nextVersion, "14.1.1") >= 0) { - plugins.push( - openNextReplacementPlugin({ - name: "opennext-14.1.1-image-optimization", - target: - /plugins(\/|\\)image-optimization(\/|\\)image-optimization\.js/g, - replacements: [ - require.resolve( - "./adapters/plugins/image-optimization/image-optimization.replacement.js", - ), - ], - }), - ); - } - - // Build Lambda code (1st pass) - // note: bundle in OpenNext package b/c the adapter relies on the - // "@aws-sdk/client-s3" package which is not a dependency in user's - // Next.js app. - await esbuildAsync( - { - entryPoints: [ - path.join(__dirname, "adapters", "image-optimization-adapter.js"), - ], - external: ["sharp", "next"], - outfile: path.join(outputPath, "index.mjs"), - plugins, - }, - options, - ); - - // Build Lambda code (2nd pass) - // note: bundle in user's Next.js app again b/c the adapter relies on the - // "next" package. And the "next" package from user's app should - // be used. We also set @opentelemetry/api as external because it seems to be - // required by Next 15 even though it's not used. - esbuildSync( - { - entryPoints: [path.join(outputPath, "index.mjs")], - external: ["sharp", "@opentelemetry/api"], - allowOverwrite: true, - outfile: path.join(outputPath, "index.mjs"), - banner: { - js: [ - "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("\n"), - }, - }, - options, - ); - - // Copy over .next/required-server-files.json file and BUILD_ID - fs.mkdirSync(path.join(outputPath, ".next")); - fs.copyFileSync( - path.join(appBuildOutputPath, ".next/required-server-files.json"), - path.join(outputPath, ".next/required-server-files.json"), - ); - fs.copyFileSync( - path.join(appBuildOutputPath, ".next/BUILD_ID"), - path.join(outputPath, ".next/BUILD_ID"), - ); - - // Sharp provides pre-build binaries for all platforms. https://github.com/lovell/sharp/blob/main/docs/install.md#cross-platform - // Target should be same as used by Lambda, see https://github.com/sst/sst/blob/ca6f763fdfddd099ce2260202d0ce48c72e211ea/packages/sst/src/constructs/NextjsSite.ts#L114 - // For SHARP_IGNORE_GLOBAL_LIBVIPS see: https://github.com/lovell/sharp/blob/main/docs/install.md#aws-lambda - - const nodeOutputPath = path.resolve(outputPath); - const sharpVersion = process.env.SHARP_VERSION ?? "0.32.6"; - - const arch = config.imageOptimization?.arch ?? "arm64"; - const nodeVersion = config.imageOptimization?.nodeVersion ?? "18"; - - //check if we are running in Windows environment then set env variables accordingly. - try { - cp.execSync( - // We might want to change the arch args to cpu args, it seems to be the documented way - `npm install --arch=${arch} --platform=linux --target=${nodeVersion} --libc=glibc --prefix="${nodeOutputPath}" sharp@${sharpVersion}`, - { - stdio: "pipe", - cwd: appPath, - env: { - ...process.env, - SHARP_IGNORE_GLOBAL_LIBVIPS: "1", - }, - }, - ); - } catch (e: any) { - logger.error(e.stdout.toString()); - logger.error(e.stderr.toString()); - logger.error("Failed to install sharp."); - } -} - -function createStaticAssets(options: BuildOptions) { - logger.info(`Bundling static assets...`); - - const { appBuildOutputPath, appPublicPath, outputDir, appPath } = options; - - // Create output folder - const outputPath = path.join(outputDir, "assets"); - fs.mkdirSync(outputPath, { recursive: true }); - - // Next.js outputs assets into multiple files. Copy into the same directory. - // Copy over: - // - .next/BUILD_ID => _next/BUILD_ID - // - .next/static => _next/static - // - public/* => * - // - app/favicon.ico or src/app/favicon.ico => favicon.ico - fs.copyFileSync( - path.join(appBuildOutputPath, ".next/BUILD_ID"), - path.join(outputPath, "BUILD_ID"), - ); - fs.cpSync( - path.join(appBuildOutputPath, ".next/static"), - path.join(outputPath, "_next", "static"), - { recursive: true }, - ); - if (fs.existsSync(appPublicPath)) { - fs.cpSync(appPublicPath, outputPath, { recursive: true }); - } - - const appSrcPath = fs.existsSync(path.join(appPath, "src")) - ? "src/app" - : "app"; - - const faviconPath = path.join(appPath, appSrcPath, "favicon.ico"); - - if (fs.existsSync(faviconPath)) { - fs.copyFileSync(faviconPath, path.join(outputPath, "favicon.ico")); - } -} - -async function createCacheAssets(options: BuildOptions) { - const { config } = options; - if (config.dangerous?.disableIncrementalCache) return; - - logger.info(`Bundling cache assets...`); - - const { appBuildOutputPath, outputDir } = options; - const packagePath = path.relative(options.monorepoRoot, appBuildOutputPath); - const buildId = getBuildId(appBuildOutputPath); - - // Copy pages to cache folder - const dotNextPath = path.join( - appBuildOutputPath, - ".next/standalone", - packagePath, - ); - const outputPath = path.join(outputDir, "cache", buildId); - [".next/server/pages", ".next/server/app"] - .map((dir) => path.join(dotNextPath, dir)) - .filter(fs.existsSync) - .forEach((dir) => fs.cpSync(dir, outputPath, { recursive: true })); - - // Remove non-cache files - const htmlPages = getHtmlPages(dotNextPath); - removeFiles( - outputPath, - (file) => - file.endsWith(".js") || - file.endsWith(".js.nft.json") || - (file.endsWith(".html") && htmlPages.has(file)), - ); - - //merge cache files into a single file - const cacheFilesPath: Record< - string, - { - meta?: string; - html?: string; - json?: string; - rsc?: string; - body?: string; - } - > = {}; - - traverseFiles( - outputPath, - () => true, - (filepath) => { - const ext = path.extname(filepath); - let newFilePath = - ext !== "" ? filepath.replace(ext, ".cache") : `${filepath}.cache`; - // Handle prefetch cache files for partial prerendering - if (newFilePath.endsWith(".prefetch.cache")) { - newFilePath = newFilePath.replace(".prefetch.cache", ".cache"); - } - switch (ext) { - case ".meta": - case ".html": - case ".json": - case ".body": - case ".rsc": - cacheFilesPath[newFilePath] = { - [ext.slice(1)]: filepath, - ...cacheFilesPath[newFilePath], - }; - break; - case ".map": - break; - default: - logger.warn(`Unknown file extension: ${ext}`); - break; - } - }, - ); - - // Generate cache file - Object.entries(cacheFilesPath).forEach(([cacheFilePath, files]) => { - const cacheFileMeta = files.meta - ? JSON.parse(fs.readFileSync(files.meta, "utf8")) - : undefined; - const cacheFileContent = { - type: files.body ? "route" : files.json ? "page" : "app", - meta: cacheFileMeta, - html: files.html ? fs.readFileSync(files.html, "utf8") : undefined, - json: files.json - ? JSON.parse(fs.readFileSync(files.json, "utf8")) - : undefined, - rsc: files.rsc ? fs.readFileSync(files.rsc, "utf8") : undefined, - body: files.body - ? fs - .readFileSync(files.body) - .toString( - isBinaryContentType(cacheFileMeta.headers["content-type"]) - ? "base64" - : "utf8", - ) - : undefined, - }; - fs.writeFileSync(cacheFilePath, JSON.stringify(cacheFileContent)); - }); - - if (!config.dangerous?.disableTagCache) { - // Generate dynamodb data - // We need to traverse the cache to find every .meta file - const metaFiles: { - tag: { S: string }; - path: { S: string }; - revalidatedAt: { N: string }; - }[] = []; - - // Compute dynamodb cache data - // Traverse files inside cache to find all meta files and cache tags associated with them - traverseFiles( - outputPath, - (file) => file.endsWith(".meta"), - (filePath) => { - const fileContent = fs.readFileSync(filePath, "utf8"); - const fileData = JSON.parse(fileContent); - if (fileData.headers?.["x-next-cache-tags"]) { - fileData.headers["x-next-cache-tags"] - .split(",") - .forEach((tag: string) => { - // TODO: We should split the tag using getDerivedTags from next.js or maybe use an in house implementation - metaFiles.push({ - tag: { S: path.posix.join(buildId, tag.trim()) }, - path: { - S: path.posix.join( - buildId, - path.relative(outputPath, filePath).replace(".meta", ""), - ), - }, - // We don't care about the revalidation time here, we just need to make sure it's there - revalidatedAt: { N: "1" }, - }); - }); - } - }, - ); - - // Copy fetch-cache to cache folder - const fetchCachePath = path.join( - appBuildOutputPath, - ".next/cache/fetch-cache", - ); - if (fs.existsSync(fetchCachePath)) { - const fetchOutputPath = path.join(outputDir, "cache", "__fetch", buildId); - fs.mkdirSync(fetchOutputPath, { recursive: true }); - fs.cpSync(fetchCachePath, fetchOutputPath, { recursive: true }); - - traverseFiles( - fetchCachePath, - () => true, - (filepath) => { - const fileContent = fs.readFileSync(filepath, "utf8"); - const fileData = JSON.parse(fileContent); - fileData?.tags?.forEach((tag: string) => { - metaFiles.push({ - tag: { S: path.posix.join(buildId, tag) }, - path: { - S: path.posix.join( - buildId, - path.relative(fetchCachePath, filepath), - ), - }, - revalidatedAt: { N: "1" }, - }); - }); - }, - ); - } - - if (metaFiles.length > 0) { - const providerPath = path.join(outputDir, "dynamodb-provider"); - - await esbuildAsync( - { - external: ["@aws-sdk/client-dynamodb"], - entryPoints: [path.join(__dirname, "adapters", "dynamo-provider.js")], - outfile: path.join(providerPath, "index.mjs"), - target: ["node18"], - plugins: [ - openNextResolvePlugin({ - fnName: "initializationFunction", - overrides: { - converter: - config.initializationFunction?.override?.converter ?? "dummy", - wrapper: config.initializationFunction?.override?.wrapper, - }, - }), - ], - }, - options, - ); - - //Copy open-next.config.mjs into the bundle - copyOpenNextConfig(options.buildDir, providerPath); - - // TODO: check if metafiles doesn't contain duplicates - fs.writeFileSync( - path.join(providerPath, "dynamodb-cache.json"), - JSON.stringify(metaFiles), - ); - } - } - - // We need to remove files later because we need the metafiles for dynamodb tags cache - removeFiles(outputPath, (file) => !file.endsWith(".cache")); -} - -/***************************/ -/* Server Helper Functions */ -/***************************/ - -export function compileCache( - options: BuildOptions, - format: "cjs" | "esm" = "cjs", -) { - const { config } = options; - const ext = format === "cjs" ? "cjs" : "mjs"; - const outfile = path.join(options.buildDir, `cache.${ext}`); - - const isAfter15 = compareSemver(options.nextVersion, "15.0.0") >= 0; - - esbuildSync( - { - external: ["next", "styled-jsx", "react", "@aws-sdk/*"], - entryPoints: [path.join(__dirname, "adapters", "cache.js")], - outfile, - target: ["node18"], - format, - banner: { - js: [ - `globalThis.disableIncrementalCache = ${ - config.dangerous?.disableIncrementalCache ?? false - };`, - `globalThis.disableDynamoDBCache = ${ - config.dangerous?.disableTagCache ?? false - };`, - `globalThis.isNextAfter15 = ${isAfter15};`, - ].join(""), - }, - }, - options, - ); - return outfile; -} - -async function createMiddleware(options: BuildOptions) { - console.info(`Bundling middleware function...`); - - const { appBuildOutputPath, config, outputDir } = options; - - // Get middleware manifest - const middlewareManifest = JSON.parse( - fs.readFileSync( - path.join(appBuildOutputPath, ".next/server/middleware-manifest.json"), - "utf8", - ), - ) as MiddlewareManifest; - - const entry = middlewareManifest.middleware["/"]; - if (!entry) { - return; - } - - // Create output folder - let outputPath = path.join(outputDir, "server-function"); - - const commonMiddlewareOptions = { - middlewareInfo: entry, - options, - appBuildOutputPath, - }; - - if (config.middleware?.external) { - outputPath = path.join(outputDir, "middleware"); - fs.mkdirSync(outputPath, { recursive: true }); - - // Copy open-next.config.mjs - copyOpenNextConfig( - options.buildDir, - outputPath, - config.middleware.override?.wrapper === "cloudflare", - ); - - // Bundle middleware - await buildEdgeBundle({ - entrypoint: path.join(__dirname, "adapters", "middleware.js"), - outfile: path.join(outputPath, "handler.mjs"), - ...commonMiddlewareOptions, - overrides: config.middleware?.override, - defaultConverter: "aws-cloudfront", - includeCache: config.dangerous?.enableCacheInterception, - additionalExternals: config.edgeExternals, - }); - } else { - await buildEdgeBundle({ - entrypoint: path.join(__dirname, "core", "edgeFunctionHandler.js"), - outfile: path.join(options.buildDir, "middleware.mjs"), - ...commonMiddlewareOptions, - onlyBuildOnce: true, - }); - } -} diff --git a/packages/open-next/src/build/buildNextApp.ts b/packages/open-next/src/build/buildNextApp.ts new file mode 100644 index 000000000..341907590 --- /dev/null +++ b/packages/open-next/src/build/buildNextApp.ts @@ -0,0 +1,24 @@ +import cp from "node:child_process"; +import path from "node:path"; + +import * as buildHelper from "./helper.js"; + +export function setStandaloneBuildMode(options: buildHelper.BuildOptions) { + // Equivalent to setting `output: "standalone"` in next.config.js + process.env.NEXT_PRIVATE_STANDALONE = "true"; + // Equivalent to setting `experimental.outputFileTracingRoot` in next.config.js + process.env.NEXT_PRIVATE_OUTPUT_TRACE_ROOT = options.monorepoRoot; +} + +export function buildNextjsApp(options: buildHelper.BuildOptions) { + const { config, packager } = options; + const command = + config.buildCommand ?? + (["bun", "npm"].includes(packager) + ? `${packager} run build` + : `${packager} build`); + cp.execSync(command, { + stdio: "inherit", + cwd: path.dirname(options.appPackageJsonPath), + }); +} diff --git a/packages/open-next/src/build/bundleNextServer.ts b/packages/open-next/src/build/bundleNextServer.ts index 5c3f5f8fa..583afb0c0 100644 --- a/packages/open-next/src/build/bundleNextServer.ts +++ b/packages/open-next/src/build/bundleNextServer.ts @@ -1,4 +1,4 @@ -import { createRequire as topLevelCreateRequire } from "node:module"; +import { createRequire } from "node:module"; import { build } from "esbuild"; import path from "path"; @@ -43,7 +43,7 @@ const externals = [ ]; export async function bundleNextServer(outputDir: string, appPath: string) { - const require = topLevelCreateRequire(`${appPath}/package.json`); + const require = createRequire(`${appPath}/package.json`); const entrypoint = require.resolve("next/dist/esm/server/next-server.js"); await build({ diff --git a/packages/open-next/src/build/compileCache.ts b/packages/open-next/src/build/compileCache.ts new file mode 100644 index 000000000..0959ccb47 --- /dev/null +++ b/packages/open-next/src/build/compileCache.ts @@ -0,0 +1,45 @@ +import path from "node:path"; + +import * as buildHelper from "./helper.js"; + +/** + * Compiles the cache adapter. + * + * @param options Build options. + * @param format Output format. + * @returns The path to the compiled file. + */ +export function compileCache( + options: buildHelper.BuildOptions, + format: "cjs" | "esm" = "cjs", +) { + const { config } = options; + const ext = format === "cjs" ? "cjs" : "mjs"; + const outFile = path.join(options.buildDir, `cache.${ext}`); + + const isAfter15 = + buildHelper.compareSemver(options.nextVersion, "15.0.0") >= 0; + + buildHelper.esbuildSync( + { + external: ["next", "styled-jsx", "react", "@aws-sdk/*"], + entryPoints: [path.join(options.openNextDistDir, "adapters", "cache.js")], + outfile: outFile, + target: ["node18"], + format, + banner: { + js: [ + `globalThis.disableIncrementalCache = ${ + config.dangerous?.disableIncrementalCache ?? false + };`, + `globalThis.disableDynamoDBCache = ${ + config.dangerous?.disableTagCache ?? false + };`, + `globalThis.isNextAfter15 = ${isAfter15};`, + ].join(""), + }, + }, + options, + ); + return outFile; +} diff --git a/packages/open-next/src/build/compileConfig.ts b/packages/open-next/src/build/compileConfig.ts index c78387011..97e0c1588 100644 --- a/packages/open-next/src/build/compileConfig.ts +++ b/packages/open-next/src/build/compileConfig.ts @@ -1,20 +1,74 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { buildSync } from "esbuild"; import { OpenNextConfig } from "types/open-next.js"; import logger from "../logger.js"; +import { validateConfig } from "./validateConfig.js"; -export function compileOpenNextConfigNode( - outputDir: string, +/** + * Compiles the OpenNext configuration. + * + * The configuration is always compiled for Node.js and for the edge only if needed. + * + * @param baseDir Directory where to look for the configuration. + * @param openNextConfigPath Override the default configuration when provided. Relative to baseDir. + * @param nodeExternals Externals for the Node.js compilation. + * @return The configuration and the build directory. + */ +export async function compileOpenNextConfig( + baseDir: string, openNextConfigPath?: string, nodeExternals?: string, ) { const sourcePath = path.join( - process.cwd(), + baseDir, openNextConfigPath ?? "open-next.config.ts", ); + + const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-next-tmp")); + let configPath = compileOpenNextConfigNode( + sourcePath, + buildDir, + nodeExternals ? nodeExternals.split(",") : [], + ); + + // On Windows, we need to use file:// protocol to load the config file using import() + if (process.platform === "win32") configPath = `file://${configPath}`; + const config = (await import(configPath)).default as OpenNextConfig; + if (!config || !config.default) { + logger.error( + `config.default cannot be empty, it should be at least {}, see more info here: https://open-next.js.org/config#configuration-file`, + ); + process.exit(1); + } + + validateConfig(config); + + // 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 || + Object.values(config.functions || {}).some((fn) => fn.runtime === "edge"); + if (!usesEdgeRuntime) { + logger.debug( + "No edge runtime found in the open-next.config.ts. Using default config.", + ); + //Nothing to do here + } else { + compileOpenNextConfigEdge(sourcePath, buildDir, config.edgeExternals ?? []); + } + + return { config, buildDir }; +} + +export function compileOpenNextConfigNode( + sourcePath: string, + outputDir: string, + externals: string[], +) { const outputPath = path.join(outputDir, "open-next.config.mjs"); //Check if open-next.config.ts exists @@ -29,7 +83,7 @@ export function compileOpenNextConfigNode( bundle: true, format: "esm", target: ["node18"], - external: nodeExternals ? nodeExternals.split(",") : [], + external: externals, platform: "node", banner: { js: [ @@ -46,38 +100,22 @@ export function compileOpenNextConfigNode( } export function compileOpenNextConfigEdge( - tempDir: string, - config: OpenNextConfig, - openNextConfigPath?: string, + sourcePath: string, + outputDir: string, + externals: string[], ) { - const sourcePath = path.join( - process.cwd(), - openNextConfigPath ?? "open-next.config.ts", - ); - const outputPath = path.join(tempDir, "open-next.config.edge.mjs"); + const outputPath = path.join(outputDir, "open-next.config.edge.mjs"); - // 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 || - Object.values(config.functions || {}).some((fn) => fn.runtime === "edge"); - if (!usesEdgeRuntime) { - logger.debug( - "No edge runtime found in the open-next.config.ts. Using default config.", - ); - //Nothing to do here - } else { - logger.info("Compiling open-next.config.ts for edge runtime.", outputPath); - buildSync({ - entryPoints: [sourcePath], - outfile: outputPath, - bundle: true, - format: "esm", - target: ["es2020"], - conditions: ["worker", "browser"], - platform: "browser", - external: config.edgeExternals ?? [], - }); - logger.info("Compiled open-next.config.ts for edge runtime."); - } + logger.info("Compiling open-next.config.ts for edge runtime.", outputPath); + buildSync({ + entryPoints: [sourcePath], + outfile: outputPath, + bundle: true, + format: "esm", + target: ["es2020"], + conditions: ["worker", "browser"], + platform: "browser", + external: externals, + }); + logger.info("Compiled open-next.config.ts for edge runtime."); } diff --git a/packages/open-next/src/build/createAssets.ts b/packages/open-next/src/build/createAssets.ts new file mode 100644 index 000000000..8e8d3c07f --- /dev/null +++ b/packages/open-next/src/build/createAssets.ts @@ -0,0 +1,262 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { isBinaryContentType } from "../adapters/binary.js"; +import logger from "../logger.js"; +import { openNextResolvePlugin } from "../plugins/resolve.js"; +import * as buildHelper from "./helper.js"; + +export function createStaticAssets(options: buildHelper.BuildOptions) { + logger.info(`Bundling static assets...`); + + const { appBuildOutputPath, appPublicPath, outputDir, appPath } = options; + + // Create output folder + const outputPath = path.join(outputDir, "assets"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Next.js outputs assets into multiple files. Copy into the same directory. + // Copy over: + // - .next/BUILD_ID => _next/BUILD_ID + // - .next/static => _next/static + // - public/* => * + // - app/favicon.ico or src/app/favicon.ico => favicon.ico + fs.copyFileSync( + path.join(appBuildOutputPath, ".next/BUILD_ID"), + path.join(outputPath, "BUILD_ID"), + ); + fs.cpSync( + path.join(appBuildOutputPath, ".next/static"), + path.join(outputPath, "_next", "static"), + { recursive: true }, + ); + if (fs.existsSync(appPublicPath)) { + fs.cpSync(appPublicPath, outputPath, { recursive: true }); + } + + const appSrcPath = fs.existsSync(path.join(appPath, "src")) + ? "src/app" + : "app"; + + const faviconPath = path.join(appPath, appSrcPath, "favicon.ico"); + + if (fs.existsSync(faviconPath)) { + fs.copyFileSync(faviconPath, path.join(outputPath, "favicon.ico")); + } +} + +export async function createCacheAssets(options: buildHelper.BuildOptions) { + const { config } = options; + if (config.dangerous?.disableIncrementalCache) return; + + logger.info(`Bundling cache assets...`); + + const { appBuildOutputPath, outputDir } = options; + const packagePath = path.relative(options.monorepoRoot, appBuildOutputPath); + const buildId = buildHelper.getBuildId(appBuildOutputPath); + + // Copy pages to cache folder + const dotNextPath = path.join( + appBuildOutputPath, + ".next/standalone", + packagePath, + ); + const outputPath = path.join(outputDir, "cache", buildId); + [".next/server/pages", ".next/server/app"] + .map((dir) => path.join(dotNextPath, dir)) + .filter(fs.existsSync) + .forEach((dir) => fs.cpSync(dir, outputPath, { recursive: true })); + + // Remove non-cache files + const htmlPages = buildHelper.getHtmlPages(dotNextPath); + buildHelper.removeFiles( + outputPath, + (file) => + file.endsWith(".js") || + file.endsWith(".js.nft.json") || + (file.endsWith(".html") && htmlPages.has(file)), + ); + + //merge cache files into a single file + const cacheFilesPath: Record< + string, + { + meta?: string; + html?: string; + json?: string; + rsc?: string; + body?: string; + } + > = {}; + + buildHelper.traverseFiles( + outputPath, + () => true, + (filepath) => { + const ext = path.extname(filepath); + let newFilePath = + ext !== "" ? filepath.replace(ext, ".cache") : `${filepath}.cache`; + // Handle prefetch cache files for partial prerendering + if (newFilePath.endsWith(".prefetch.cache")) { + newFilePath = newFilePath.replace(".prefetch.cache", ".cache"); + } + switch (ext) { + case ".meta": + case ".html": + case ".json": + case ".body": + case ".rsc": + cacheFilesPath[newFilePath] = { + [ext.slice(1)]: filepath, + ...cacheFilesPath[newFilePath], + }; + break; + case ".map": + break; + default: + logger.warn(`Unknown file extension: ${ext}`); + break; + } + }, + ); + + // Generate cache file + Object.entries(cacheFilesPath).forEach(([cacheFilePath, files]) => { + const cacheFileMeta = files.meta + ? JSON.parse(fs.readFileSync(files.meta, "utf8")) + : undefined; + const cacheFileContent = { + type: files.body ? "route" : files.json ? "page" : "app", + meta: cacheFileMeta, + html: files.html ? fs.readFileSync(files.html, "utf8") : undefined, + json: files.json + ? JSON.parse(fs.readFileSync(files.json, "utf8")) + : undefined, + rsc: files.rsc ? fs.readFileSync(files.rsc, "utf8") : undefined, + body: files.body + ? fs + .readFileSync(files.body) + .toString( + isBinaryContentType(cacheFileMeta.headers["content-type"]) + ? "base64" + : "utf8", + ) + : undefined, + }; + fs.writeFileSync(cacheFilePath, JSON.stringify(cacheFileContent)); + }); + + if (!config.dangerous?.disableTagCache) { + // Generate dynamodb data + // We need to traverse the cache to find every .meta file + const metaFiles: { + tag: { S: string }; + path: { S: string }; + revalidatedAt: { N: string }; + }[] = []; + + // Compute dynamodb cache data + // Traverse files inside cache to find all meta files and cache tags associated with them + buildHelper.traverseFiles( + outputPath, + (file) => file.endsWith(".meta"), + (filePath) => { + const fileContent = fs.readFileSync(filePath, "utf8"); + const fileData = JSON.parse(fileContent); + if (fileData.headers?.["x-next-cache-tags"]) { + fileData.headers["x-next-cache-tags"] + .split(",") + .forEach((tag: string) => { + // TODO: We should split the tag using getDerivedTags from next.js or maybe use an in house implementation + metaFiles.push({ + tag: { S: path.posix.join(buildId, tag.trim()) }, + path: { + S: path.posix.join( + buildId, + path.relative(outputPath, filePath).replace(".meta", ""), + ), + }, + // We don't care about the revalidation time here, we just need to make sure it's there + revalidatedAt: { N: "1" }, + }); + }); + } + }, + ); + + // Copy fetch-cache to cache folder + const fetchCachePath = path.join( + appBuildOutputPath, + ".next/cache/fetch-cache", + ); + if (fs.existsSync(fetchCachePath)) { + const fetchOutputPath = path.join(outputDir, "cache", "__fetch", buildId); + fs.mkdirSync(fetchOutputPath, { recursive: true }); + fs.cpSync(fetchCachePath, fetchOutputPath, { recursive: true }); + + buildHelper.traverseFiles( + fetchCachePath, + () => true, + (filepath) => { + const fileContent = fs.readFileSync(filepath, "utf8"); + const fileData = JSON.parse(fileContent); + fileData?.tags?.forEach((tag: string) => { + metaFiles.push({ + tag: { S: path.posix.join(buildId, tag) }, + path: { + S: path.posix.join( + buildId, + path.relative(fetchCachePath, filepath), + ), + }, + revalidatedAt: { N: "1" }, + }); + }); + }, + ); + } + + // TODO: Extract the code below to a compileTagCacheProvider function + if (metaFiles.length > 0) { + const providerPath = path.join(outputDir, "dynamodb-provider"); + + await buildHelper.esbuildAsync( + { + external: ["@aws-sdk/client-dynamodb"], + entryPoints: [ + path.join( + options.openNextDistDir, + "adapters", + "dynamo-provider.js", + ), + ], + outfile: path.join(providerPath, "index.mjs"), + target: ["node18"], + plugins: [ + openNextResolvePlugin({ + fnName: "initializationFunction", + overrides: { + converter: + config.initializationFunction?.override?.converter ?? "dummy", + wrapper: config.initializationFunction?.override?.wrapper, + }, + }), + ], + }, + options, + ); + + //Copy open-next.config.mjs into the bundle + buildHelper.copyOpenNextConfig(options.buildDir, providerPath); + + // TODO: check if metafiles doesn't contain duplicates + fs.writeFileSync( + path.join(providerPath, "dynamodb-cache.json"), + JSON.stringify(metaFiles), + ); + } + } + + // We need to remove files later because we need the metafiles for dynamodb tags cache + buildHelper.removeFiles(outputPath, (file) => !file.endsWith(".cache")); +} diff --git a/packages/open-next/src/build/createImageOptimizationBundle.ts b/packages/open-next/src/build/createImageOptimizationBundle.ts new file mode 100644 index 000000000..66ed29ae5 --- /dev/null +++ b/packages/open-next/src/build/createImageOptimizationBundle.ts @@ -0,0 +1,136 @@ +import cp from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import logger from "../logger.js"; +import { openNextReplacementPlugin } from "../plugins/replacement.js"; +import { openNextResolvePlugin } from "../plugins/resolve.js"; +import * as buildHelper from "./helper.js"; + +const require = createRequire(import.meta.url); + +export async function createImageOptimizationBundle( + options: buildHelper.BuildOptions, +) { + logger.info(`Bundling image optimization function...`); + + const { appPath, appBuildOutputPath, config, outputDir } = options; + + // Create output folder + const outputPath = path.join(outputDir, "image-optimization-function"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Copy open-next.config.mjs into the bundle + buildHelper.copyOpenNextConfig(options.buildDir, outputPath); + + const plugins = [ + openNextResolvePlugin({ + fnName: "imageOptimization", + overrides: { + converter: config.imageOptimization?.override?.converter, + wrapper: config.imageOptimization?.override?.wrapper, + imageLoader: config.imageOptimization?.loader, + }, + }), + ]; + + if (buildHelper.compareSemver(options.nextVersion, "14.1.1") >= 0) { + plugins.push( + openNextReplacementPlugin({ + name: "opennext-14.1.1-image-optimization", + target: + /plugins(\/|\\)image-optimization(\/|\\)image-optimization\.js/g, + replacements: [ + require.resolve( + "../adapters/plugins/image-optimization/image-optimization.replacement.js", + ), + ], + }), + ); + } + + // Build Lambda code (1st pass) + // note: bundle in OpenNext package b/c the adapter relies on the + // "@aws-sdk/client-s3" package which is not a dependency in user's + // Next.js app. + await buildHelper.esbuildAsync( + { + entryPoints: [ + path.join( + options.openNextDistDir, + "adapters", + "image-optimization-adapter.js", + ), + ], + external: ["sharp", "next"], + outfile: path.join(outputPath, "index.mjs"), + plugins, + }, + options, + ); + + // Build Lambda code (2nd pass) + // note: bundle in user's Next.js app again b/c the adapter relies on the + // "next" package. And the "next" package from user's app should + // be used. We also set @opentelemetry/api as external because it seems to be + // required by Next 15 even though it's not used. + buildHelper.esbuildSync( + { + entryPoints: [path.join(outputPath, "index.mjs")], + external: ["sharp", "@opentelemetry/api"], + allowOverwrite: true, + outfile: path.join(outputPath, "index.mjs"), + banner: { + js: [ + "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("\n"), + }, + }, + options, + ); + + // Copy over .next/required-server-files.json file and BUILD_ID + fs.mkdirSync(path.join(outputPath, ".next")); + fs.copyFileSync( + path.join(appBuildOutputPath, ".next/required-server-files.json"), + path.join(outputPath, ".next/required-server-files.json"), + ); + fs.copyFileSync( + path.join(appBuildOutputPath, ".next/BUILD_ID"), + path.join(outputPath, ".next/BUILD_ID"), + ); + + // Sharp provides pre-build binaries for all platforms. https://github.com/lovell/sharp/blob/main/docs/install.md#cross-platform + // Target should be same as used by Lambda, see https://github.com/sst/sst/blob/ca6f763fdfddd099ce2260202d0ce48c72e211ea/packages/sst/src/constructs/NextjsSite.ts#L114 + // For SHARP_IGNORE_GLOBAL_LIBVIPS see: https://github.com/lovell/sharp/blob/main/docs/install.md#aws-lambda + + const nodeOutputPath = path.resolve(outputPath); + const sharpVersion = process.env.SHARP_VERSION ?? "0.32.6"; + + const arch = config.imageOptimization?.arch ?? "arm64"; + const nodeVersion = config.imageOptimization?.nodeVersion ?? "18"; + + //check if we are running in Windows environment then set env variables accordingly. + try { + cp.execSync( + // We might want to change the arch args to cpu args, it seems to be the documented way + `npm install --arch=${arch} --platform=linux --target=${nodeVersion} --libc=glibc --prefix="${nodeOutputPath}" sharp@${sharpVersion}`, + { + stdio: "pipe", + cwd: appPath, + env: { + ...process.env, + SHARP_IGNORE_GLOBAL_LIBVIPS: "1", + }, + }, + ); + } catch (e: any) { + logger.error(e.stdout.toString()); + logger.error(e.stderr.toString()); + logger.error("Failed to install sharp."); + } +} diff --git a/packages/open-next/src/build/createMiddleware.ts b/packages/open-next/src/build/createMiddleware.ts new file mode 100644 index 000000000..7af774d6a --- /dev/null +++ b/packages/open-next/src/build/createMiddleware.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; + +import logger from "../logger.js"; +import { type MiddlewareManifest } from "../types/next-types.js"; +import { buildEdgeBundle } from "./edge/createEdgeBundle.js"; +import * as buildHelper from "./helper.js"; + +export async function createMiddleware(options: buildHelper.BuildOptions) { + logger.info(`Bundling middleware function...`); + + const { appBuildOutputPath, config, outputDir } = options; + + // Get middleware manifest + const middlewareManifest = JSON.parse( + fs.readFileSync( + path.join(appBuildOutputPath, ".next/server/middleware-manifest.json"), + "utf8", + ), + ) as MiddlewareManifest; + + const entry = middlewareManifest.middleware["/"]; + if (!entry) { + return; + } + + // Create output folder + let outputPath = path.join(outputDir, "server-function"); + + const commonMiddlewareOptions = { + middlewareInfo: entry, + options, + appBuildOutputPath, + }; + + if (config.middleware?.external) { + outputPath = path.join(outputDir, "middleware"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Copy open-next.config.mjs + buildHelper.copyOpenNextConfig( + options.buildDir, + outputPath, + config.middleware.override?.wrapper === "cloudflare", + ); + + // Bundle middleware + await buildEdgeBundle({ + entrypoint: path.join( + options.openNextDistDir, + "adapters", + "middleware.js", + ), + outfile: path.join(outputPath, "handler.mjs"), + ...commonMiddlewareOptions, + overrides: config.middleware?.override, + defaultConverter: "aws-cloudfront", + includeCache: config.dangerous?.enableCacheInterception, + additionalExternals: config.edgeExternals, + }); + } else { + await buildEdgeBundle({ + entrypoint: path.join( + options.openNextDistDir, + "core", + "edgeFunctionHandler.js", + ), + outfile: path.join(options.buildDir, "middleware.mjs"), + ...commonMiddlewareOptions, + onlyBuildOnce: true, + }); + } +} diff --git a/packages/open-next/src/build/createRevalidationBundle.ts b/packages/open-next/src/build/createRevalidationBundle.ts new file mode 100644 index 000000000..e3eb59b06 --- /dev/null +++ b/packages/open-next/src/build/createRevalidationBundle.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; + +import logger from "../logger.js"; +import { openNextResolvePlugin } from "../plugins/resolve.js"; +import * as buildHelper from "./helper.js"; + +export async function createRevalidationBundle( + options: buildHelper.BuildOptions, +) { + logger.info(`Bundling revalidation function...`); + + const { appBuildOutputPath, config, outputDir } = options; + + // Create output folder + const outputPath = path.join(outputDir, "revalidation-function"); + fs.mkdirSync(outputPath, { recursive: true }); + + //Copy open-next.config.mjs into the bundle + buildHelper.copyOpenNextConfig(options.buildDir, outputPath); + + // Build Lambda code + await buildHelper.esbuildAsync( + { + external: ["next", "styled-jsx", "react"], + entryPoints: [ + path.join(options.openNextDistDir, "adapters", "revalidate.js"), + ], + outfile: path.join(outputPath, "index.mjs"), + plugins: [ + openNextResolvePlugin({ + fnName: "revalidate", + overrides: { + converter: + config.revalidate?.override?.converter ?? "sqs-revalidate", + wrapper: config.revalidate?.override?.wrapper, + }, + }), + ], + }, + options, + ); + + // Copy over .next/prerender-manifest.json file + fs.copyFileSync( + path.join(appBuildOutputPath, ".next", "prerender-manifest.json"), + path.join(outputPath, "prerender-manifest.json"), + ); +} diff --git a/packages/open-next/src/build/createServerBundle.ts b/packages/open-next/src/build/createServerBundle.ts index 8f78bf9bd..b863a2d87 100644 --- a/packages/open-next/src/build/createServerBundle.ts +++ b/packages/open-next/src/build/createServerBundle.ts @@ -1,36 +1,26 @@ -import { existsSync } from "node:fs"; -import { createRequire as topLevelCreateRequire } from "node:module"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; -import fs from "fs"; -import path from "path"; import { FunctionOptions, OpenNextConfig, SplittedFunctionOptions, } from "types/open-next"; -import url from "url"; -import { compileCache } from "../build.js"; import logger from "../logger.js"; import { minifyAll } from "../minimize-js.js"; import { openNextReplacementPlugin } from "../plugins/replacement.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import { bundleNextServer } from "./bundleNextServer.js"; +import { compileCache } from "./compileCache.js"; import { copyTracedFiles } from "./copyTracedFiles.js"; import { generateEdgeBundle } from "./edge/createEdgeBundle.js"; -import type { BuildOptions } from "./helper.js"; -import { - compareSemver, - copyEnvFile, - copyOpenNextConfig, - esbuildAsync, - traverseFiles, -} from "./helper.js"; +import * as buildHelper from "./helper.js"; -const require = topLevelCreateRequire(import.meta.url); -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const require = createRequire(import.meta.url); -export async function createServerBundle(options: BuildOptions) { +export async function createServerBundle(options: buildHelper.BuildOptions) { const { config } = options; const foundRoutes = new Set(); // Get all functions to build @@ -77,9 +67,9 @@ export async function createServerBundle(options: BuildOptions) { ); // Find app dir routes - if (existsSync(path.join(serverPath, "app"))) { + if (fs.existsSync(path.join(serverPath, "app"))) { const appPath = path.join(serverPath, "app"); - traverseFiles( + buildHelper.traverseFiles( appPath, (file) => { if (file.endsWith("page.js") || file.endsWith("route.js")) { @@ -95,9 +85,9 @@ export async function createServerBundle(options: BuildOptions) { } // Find pages dir routes - if (existsSync(path.join(serverPath, "pages"))) { + if (fs.existsSync(path.join(serverPath, "pages"))) { const pagePath = path.join(serverPath, "pages"); - traverseFiles( + buildHelper.traverseFiles( pagePath, (file) => { if (file.endsWith(".js")) { @@ -124,7 +114,7 @@ export async function createServerBundle(options: BuildOptions) { async function generateBundle( name: string, config: OpenNextConfig, - options: BuildOptions, + options: buildHelper.BuildOptions, fnOptions: SplittedFunctionOptions, ) { const { appPath, appBuildOutputPath, outputDir, monorepoRoot } = options; @@ -161,7 +151,7 @@ async function generateBundle( // Copy middleware if ( !config.middleware?.external && - existsSync(path.join(options.buildDir, "middleware.mjs")) + fs.existsSync(path.join(options.buildDir, "middleware.mjs")) ) { fs.copyFileSync( path.join(options.buildDir, "middleware.mjs"), @@ -170,10 +160,13 @@ async function generateBundle( } // Copy open-next.config.mjs - copyOpenNextConfig(options.buildDir, path.join(outputPath, packagePath)); + buildHelper.copyOpenNextConfig( + options.buildDir, + path.join(outputPath, packagePath), + ); // Copy env files - copyEnvFile(appBuildOutputPath, packagePath, outputPath); + buildHelper.copyEnvFile(appBuildOutputPath, packagePath, outputPath); // Copy all necessary traced files await copyTracedFiles( @@ -190,13 +183,15 @@ async function generateBundle( // Next.js app. const disableNextPrebundledReact = - compareSemver(options.nextVersion, "13.5.1") >= 0 || - compareSemver(options.nextVersion, "13.4.1") <= 0; + buildHelper.compareSemver(options.nextVersion, "13.5.1") >= 0 || + buildHelper.compareSemver(options.nextVersion, "13.4.1") <= 0; const overrides = fnOptions.override ?? {}; - const isBefore13413 = compareSemver(options.nextVersion, "13.4.13") <= 0; - const isAfter141 = compareSemver(options.nextVersion, "14.0.4") >= 0; + const isBefore13413 = + buildHelper.compareSemver(options.nextVersion, "13.4.13") <= 0; + const isAfter141 = + buildHelper.compareSemver(options.nextVersion, "14.0.4") >= 0; const disableRouting = isBefore13413 || config.middleware?.external; const plugins = [ @@ -239,9 +234,11 @@ async function generateBundle( } const outfileExt = fnOptions.runtime === "deno" ? "ts" : "mjs"; - await esbuildAsync( + await buildHelper.esbuildAsync( { - entryPoints: [path.join(__dirname, "../adapters", "server-adapter.js")], + entryPoints: [ + path.join(options.openNextDistDir, "adapters", "server-adapter.js"), + ], external: ["next", "./middleware.mjs", "./next-server.runtime.prod.js"], outfile: path.join(outputPath, packagePath, `index.${outfileExt}`), banner: { diff --git a/packages/open-next/src/build/createWarmerBundle.ts b/packages/open-next/src/build/createWarmerBundle.ts new file mode 100644 index 000000000..c765ddfb9 --- /dev/null +++ b/packages/open-next/src/build/createWarmerBundle.ts @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import path from "node:path"; + +import logger from "../logger.js"; +import { openNextResolvePlugin } from "../plugins/resolve.js"; +import * as buildHelper from "./helper.js"; + +export async function createWarmerBundle(options: buildHelper.BuildOptions) { + logger.info(`Bundling warmer function...`); + + const { config, outputDir } = options; + + // Create output folder + const outputPath = path.join(outputDir, "warmer-function"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Copy open-next.config.mjs into the bundle + buildHelper.copyOpenNextConfig(options.buildDir, outputPath); + + // Build Lambda code + // note: bundle in OpenNext package b/c the adatper relys on the + // "serverless-http" package which is not a dependency in user's + // Next.js app. + await buildHelper.esbuildAsync( + { + entryPoints: [ + path.join(options.openNextDistDir, "adapters", "warmer-function.js"), + ], + external: ["next"], + outfile: path.join(outputPath, "index.mjs"), + plugins: [ + openNextResolvePlugin({ + overrides: { + converter: config.warmer?.override?.converter ?? "dummy", + wrapper: config.warmer?.override?.wrapper, + }, + fnName: "warmer", + }), + ], + banner: { + js: [ + "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, + ); +} diff --git a/packages/open-next/src/build/edge/createEdgeBundle.ts b/packages/open-next/src/build/edge/createEdgeBundle.ts index 3ddafaba6..acbe32d05 100644 --- a/packages/open-next/src/build/edge/createEdgeBundle.ts +++ b/packages/open-next/src/build/edge/createEdgeBundle.ts @@ -1,5 +1,4 @@ import { mkdirSync } from "node:fs"; -import url from "node:url"; import { build } from "esbuild"; import fs from "fs"; @@ -19,8 +18,6 @@ import { openNextReplacementPlugin } from "../../plugins/replacement.js"; import { openNextResolvePlugin } from "../../plugins/resolve.js"; import { BuildOptions, copyOpenNextConfig, esbuildAsync } from "../helper.js"; -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); - interface BuildEdgeBundleOptions { appBuildOutputPath: string; middlewareInfo: MiddlewareInfo; @@ -99,8 +96,8 @@ export async function buildEdgeBundle({ middlewareInfo, nextDir: path.join(appBuildOutputPath, ".next"), edgeFunctionHandlerPath: path.join( - __dirname, - "../../core", + options.openNextDistDir, + "core", "edgeFunctionHandler.js", ), isInCloudfare, @@ -222,7 +219,11 @@ export async function generateEdgeBundle( await buildEdgeBundle({ appBuildOutputPath, middlewareInfo: fn, - entrypoint: path.join(__dirname, "../../adapters", "edge-adapter.js"), + entrypoint: path.join( + options.openNextDistDir, + "adapters", + "edge-adapter.js", + ), outfile: path.join(outputPath, "index.mjs"), options, overrides: fnOptions.override, diff --git a/packages/open-next/src/build/helper.ts b/packages/open-next/src/build/helper.ts index d3c95d45f..4948b7514 100644 --- a/packages/open-next/src/build/helper.ts +++ b/packages/open-next/src/build/helper.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { createRequire as topLevelCreateRequire } from "node:module"; +import { createRequire } from "node:module"; import path from "node:path"; import url from "node:url"; @@ -12,12 +12,16 @@ import { OpenNextConfig } from "types/open-next.js"; import logger from "../logger.js"; -const require = topLevelCreateRequire(import.meta.url); +const require = createRequire(import.meta.url); const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); export type BuildOptions = ReturnType; -export function normalizeOptions(config: OpenNextConfig, tempBuildDir: string) { +export function normalizeOptions( + config: OpenNextConfig, + distDir: string, + tempBuildDir: string, +) { const appPath = path.join(process.cwd(), config.appPath || "."); const buildOutputPath = path.join( process.cwd(), @@ -50,6 +54,7 @@ export function normalizeOptions(config: OpenNextConfig, tempBuildDir: string) { monorepoRoot, nextVersion: getNextVersion(appPath), openNextVersion: getOpenNextVersion(), + openNextDistDir: distDir, outputDir, packager, tempBuildDir, @@ -305,3 +310,42 @@ export function copyEnvFile( fs.copyFileSync(envProdPath, path.join(baseOutputPath, ".env.production")); } } + +/** + * Check we are in a Nextjs app by looking for the Nextjs config file. + */ +export function checkRunningInsideNextjsApp(options: BuildOptions) { + const { appPath } = options; + const extension = ["js", "cjs", "mjs", "ts"].find((ext) => + fs.existsSync(path.join(appPath, `next.config.${ext}`)), + ); + if (!extension) { + logger.error( + "Error: next.config.js not found. Please make sure you are running this command inside a Next.js app.", + ); + process.exit(1); + } +} + +export function printNextjsVersion(options: BuildOptions) { + logger.info(`Next.js version : ${options.nextVersion}`); +} + +export function printOpenNextVersion(options: BuildOptions) { + logger.info(`OpenNext v${options.openNextVersion}`); +} + +/** + * Populates the build directory with the compiled configuration files. + * + * We need to get the build relative to the cwd to find the compiled config. + * This is needed for the case where the app is a single-version monorepo + * and the package.json is in the root of the monorepo where the build is in + * the app directory, but the compiled config is in the root of the monorepo. + */ +export function initOutputDir(options: BuildOptions) { + fs.rmSync(options.outputDir, { recursive: true, force: true }); + const { buildDir } = options; + fs.mkdirSync(buildDir, { recursive: true }); + fs.cpSync(options.tempBuildDir, buildDir, { recursive: true }); +} diff --git a/packages/open-next/src/build/utils.ts b/packages/open-next/src/build/utils.ts new file mode 100644 index 000000000..9868503a6 --- /dev/null +++ b/packages/open-next/src/build/utils.ts @@ -0,0 +1,31 @@ +import os from "node:os"; + +import logger from "../logger.js"; + +export function printHeader(header: string) { + header = `OpenNext — ${header}`; + logger.info( + [ + "", + "┌" + "─".repeat(header.length + 2) + "┐", + `│ ${header} │`, + "└" + "─".repeat(header.length + 2) + "┘", + "", + ].join("\n"), + ); +} + +/** + * Displays a warning on windows platform. + */ +export function showWarningOnWindows() { + if (os.platform() !== "win32") return; + + logger.warn("OpenNext is not fully compatible with Windows."); + logger.warn( + "For optimal performance, it is recommended to use Windows Subsystem for Linux (WSL).", + ); + logger.warn( + "While OpenNext may function on Windows, it could encounter unpredictable failures during runtime.", + ); +}