import { cp, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { stageStandaloneArtifact } from "./artifact-stage.ts"; import { type BuildSettings, nextOutputRootFromStandaloneDirectory, resolveBuildSettings, } from "./build-settings.ts"; import type { BuildArtifact, BuildStrategy } from "./build-strategy.ts"; import { hasPackageDependency, hasRootFile } from "./build-strategy.ts"; import { defaultHttpPortForBuildType } from "./config/frameworks.ts"; import { type BuildCommandIo, runBuildCommand } from "./workspace.ts"; const NEXT_CONFIG_FILENAMES = [ "next.config.js", "next.config.mjs", "next.config.ts", "next.config.mts", ]; /** * Build strategy for Next.js applications. Runs the resolved build command * (the user's `package.json` build script, or `next build`), then stages the * `output: "standalone"` tree — materializing workspace symlinks and hoisting * isolated-store dependencies. Apps built without standalone output fall back * to packaging the full tree and serving with `next start`, unless * `requireStandalone` is set (a disk-limited deploy target opts out of that). */ export class NextjsBuild implements BuildStrategy { readonly #appPath: string; readonly #buildSettings?: BuildSettings; readonly #io?: BuildCommandIo; readonly #requireStandalone: boolean; constructor(options: { appPath: string; buildSettings?: BuildSettings; io?: BuildCommandIo; /** Fail instead of falling back to the full-tree `next start` artifact. */ requireStandalone?: boolean; }) { this.#appPath = options.appPath; this.#buildSettings = options.buildSettings; this.#io = options.io; this.#requireStandalone = options.requireStandalone ?? false; } async canBuild(signal?: AbortSignal): Promise { return ( (await hasRootFile(this.#appPath, NEXT_CONFIG_FILENAMES, signal)) || (await hasPackageDependency(this.#appPath, ["next"], signal)) ); } async execute(signal?: AbortSignal): Promise { signal?.throwIfAborted(); const settings = this.#buildSettings ?? (await resolveBuildSettings({ appPath: this.#appPath, buildType: "nextjs", signal, })); if (settings.buildCommand) { await runBuildCommand({ appPath: this.#appPath, command: settings.buildCommand, failurePrefix: "Next.js", env: this.#io?.env, onOutput: this.#io?.onOutput, signal, }); } const standaloneDir = path.join(this.#appPath, settings.outputDirectory); if (!(await directoryExists(standaloneDir))) { if (this.#requireStandalone) { throw new Error( `Next.js build did not produce standalone output at ${settings.outputDirectory}. ` + 'Set output: "standalone" in your next.config; this deploy target requires it.', ); } // No `output: "standalone"` in next.config: the build succeeded, so // package the full tree and serve with `next start`. Bigger artifact, // same running app. return stageFullTreeFallbackArtifact(this.#appPath, signal); } const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-")); try { const artifactDir = path.join(outDir, "app"); await stageStandaloneArtifact({ standaloneDir, artifactDir, appPath: this.#appPath, signal, }); const entrypoint = await findStandaloneEntrypoint(artifactDir); await copyStaticAssets({ appPath: this.#appPath, artifactDir, outputRoot: nextOutputRootFromStandaloneDirectory( settings.outputDirectory, ), entrypoint, signal, }); return { directory: artifactDir, entrypoint, defaultPortMapping: { http: defaultHttpPortForBuildType("nextjs") }, cleanup: () => rm(outDir, { recursive: true, force: true }), }; } catch (error) { await rm(outDir, { recursive: true, force: true }); throw error; } } } const FULL_TREE_ENTRYPOINT = "prisma-next-start.cjs"; // Bootstrap for apps built without `output: "standalone"`. Entering through // the CLI bin (not `next/dist/server/lib/start-server`) keeps Next in charge // of config loading, which is the part that drifts across Next majors. const FULL_TREE_START_SOURCE = [ // The runtime unpacks us at the artifact root, but `next start` resolves // `.next` from cwd. "process.chdir(__dirname);", 'process.env.NODE_ENV = "production";', 'process.argv.push("start", "-p", process.env.PORT ?? "3000");', 'require("next/dist/bin/next");', "", ].join("\n"); async function stageFullTreeFallbackArtifact( appPath: string, signal?: AbortSignal, ): Promise { const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-")); try { const artifactDir = path.join(outDir, "app"); // node_modules ships on purpose: without standalone output the artifact // must carry the full runtime dependency tree for `next start`. signal?.throwIfAborted(); await cp(appPath, artifactDir, { recursive: true, // Keep relative symlinks relative (node_modules/.bin/*): the default // rewrites them to absolute paths into the source tree, which the // archiver rejects as escaping the artifact root. verbatimSymlinks: true, filter: (source) => !isExcludedFromFullTree(path.basename(source)), }); signal?.throwIfAborted(); await writeFile( path.join(artifactDir, FULL_TREE_ENTRYPOINT), FULL_TREE_START_SOURCE, ); return { directory: artifactDir, entrypoint: FULL_TREE_ENTRYPOINT, defaultPortMapping: { http: defaultHttpPortForBuildType("nextjs") }, cleanup: () => rm(outDir, { recursive: true, force: true }), }; } catch (error) { await rm(outDir, { recursive: true, force: true }); throw error; } } /** Excludes VCS internals and dotenv files (local secrets, superseded by the deploy env). */ function isExcludedFromFullTree(basename: string): boolean { return ( basename === ".git" || basename === ".env" || basename.startsWith(".env.") ); } async function copyStaticAssets(options: { appPath: string; artifactDir: string; outputRoot: string; entrypoint: string; signal?: AbortSignal; }): Promise { const serverSubpath = serverSubpathOf(options.entrypoint); const serverDir = serverSubpath ? path.join(options.artifactDir, serverSubpath) : options.artifactDir; const publicDir = path.join(options.appPath, "public"); if (await directoryExists(publicDir)) { options.signal?.throwIfAborted(); await cp(publicDir, path.join(serverDir, "public"), { recursive: true, verbatimSymlinks: true, }); } const staticDir = path.join(options.appPath, options.outputRoot, "static"); if (await directoryExists(staticDir)) { options.signal?.throwIfAborted(); await cp(staticDir, path.join(serverDir, options.outputRoot, "static"), { recursive: true, verbatimSymlinks: true, }); } } /** * Locates `server.js` in the staged standalone output. In monorepos Next * preserves the workspace-relative path (e.g. `apps/web/server.js`), so the * entrypoint is the shallowest match. `node_modules` is skipped because * third-party packages may ship their own `server.js`. */ async function findStandaloneEntrypoint(artifactDir: string): Promise { const rootStat = await stat(path.join(artifactDir, "server.js")).catch( () => null, ); if (rootStat?.isFile()) { return "server.js"; } const candidates: string[] = []; await walk(artifactDir); candidates.sort( (left, right) => left.split("/").length - right.split("/").length || left.localeCompare(right), ); const selected = candidates[0]; if (!selected) { throw new Error( `Next.js standalone output did not contain server.js in ${artifactDir}`, ); } return selected; async function walk(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (entry.name === "node_modules") { continue; } const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { await walk(fullPath); } else if (entry.isFile() && entry.name === "server.js") { candidates.push( path.relative(artifactDir, fullPath).split(path.sep).join("/"), ); } } } } function serverSubpathOf(entrypoint: string): string { const dir = path.posix.dirname(entrypoint); return dir === "." ? "" : dir; } async function directoryExists(dirPath: string): Promise { const s = await stat(dirPath).catch(() => null); return s?.isDirectory() ?? false; }