import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { nodeFileTrace } from "@vercel/nft"; import { type BuildSettings, joinPosix, resolveBuildSettings, } from "./build-settings.ts"; import type { BuildArtifact, BuildStrategy } from "./build-strategy.ts"; import { hasPackageDependency, hasRootFile, runPackageCli, } from "./build-strategy.ts"; import { defaultHttpPortForBuildType } from "./config/frameworks.ts"; import { resolveSourceRoot } from "./config/source-root.ts"; import { type BuildCommandIo, readPackageManifest, runBuildCommand, } from "./workspace.ts"; const NEST_CLI_FILENAME = "nest-cli.json"; /** Compiled entrypoints probed, in order, when config does not resolve one. */ const DEFAULT_COMPILED_ENTRYPOINTS = ["dist/src/main.js", "dist/main.js"]; /** * Build strategy for NestJS applications. Runs the user's build (so `nest build` * and any `prisma generate` in the `build` script run), resolves the compiled * entrypoint, then stages a lean artifact by file-tracing that entrypoint with * `@vercel/nft`: it ships only the compiled output and the `node_modules` files * actually reachable, not the full dependency tree. NestJS has no Next-style * standalone output, so the trace is the lean equivalent that keeps the * artifact under the compute runtime's disk ceiling. */ export class NestjsBuild implements BuildStrategy { readonly #appPath: string; readonly #buildSettings?: BuildSettings; readonly #io?: BuildCommandIo; constructor(options: { appPath: string; buildSettings?: BuildSettings; io?: BuildCommandIo; }) { this.#appPath = options.appPath; this.#buildSettings = options.buildSettings; this.#io = options.io; } async canBuild(signal?: AbortSignal): Promise { return ( (await hasRootFile(this.#appPath, [NEST_CLI_FILENAME], signal)) || (await hasPackageDependency(this.#appPath, ["@nestjs/core"], signal)) ); } async execute(signal?: AbortSignal): Promise { signal?.throwIfAborted(); const settings = this.#buildSettings ?? (await resolveBuildSettings({ appPath: this.#appPath, buildType: "nestjs", signal, })); // `resolveBuildSettings` always returns a non-null `nest build` fallback, so // route by source: a user `build` script runs as-is, the framework default // goes through the launcher ladder (local bin -> npx -> bunx). const usingFrameworkDefault = settings.buildCommand === null || settings.buildCommandSource === "NestJS default"; if (!usingFrameworkDefault && settings.buildCommand) { await runBuildCommand({ appPath: this.#appPath, command: settings.buildCommand, failurePrefix: "NestJS", env: this.#io?.env, onOutput: this.#io?.onOutput, signal, }); } else { await runPackageCli({ appPath: this.#appPath, cliName: "nest", args: ["build"], failurePrefix: "NestJS", missingMessage: "Could not find the Nest CLI. Add a `build` script to package.json, install @nestjs/cli, or ensure npx/bunx is available.", env: this.#io?.env, onOutput: this.#io?.onOutput, signal, }); } signal?.throwIfAborted(); const compiledEntry = await this.#resolveCompiledEntrypoint(signal); const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-")); try { const artifactDir = path.join(outDir, "app"); const entrypoint = await stageTracedArtifact({ appPath: this.#appPath, artifactDir, compiledEntry, signal, }); return { directory: artifactDir, entrypoint, defaultPortMapping: { http: defaultHttpPortForBuildType("nestjs") }, cleanup: () => rm(outDir, { recursive: true, force: true }), }; } catch (error) { await rm(outDir, { recursive: true, force: true }); throw error; } } /** * Resolves the compiled entrypoint relative to the app root, using posix * separators. Prefers `package.json` `main`, then the path computed from * `nest-cli.json` and the tsconfig `outDir`, then the common defaults. */ async #resolveCompiledEntrypoint(signal?: AbortSignal): Promise { const candidates: string[] = []; const fromMain = await this.#mainFieldEntrypoint(signal); if (fromMain) { candidates.push(fromMain); } const fromConfig = await this.#configuredCompiledEntrypoint(signal); if (fromConfig) { candidates.push(fromConfig); } candidates.push(...DEFAULT_COMPILED_ENTRYPOINTS); for (const candidate of candidates) { signal?.throwIfAborted(); const normalized = normalizeRelative(candidate); if (!normalized) { continue; } if (await isFile(path.join(this.#appPath, normalized))) { return normalized; } } throw new Error( `NestJS build did not produce a compiled entrypoint. Looked for ${candidates.join(", ")} under ${this.#appPath}. ` + "Ensure `nest build` ran and check the `main` field, nest-cli.json, or tsconfig outDir.", ); } async #mainFieldEntrypoint(signal?: AbortSignal): Promise { const manifest = await readPackageManifest(this.#appPath, signal); return typeof manifest?.main === "string" && manifest.main.trim().length > 0 ? manifest.main.trim() : null; } async #configuredCompiledEntrypoint( signal?: AbortSignal, ): Promise { const nestCli = await readNestCliConfig(this.#appPath, signal); const sourceRoot = nestCli.sourceRoot ?? "src"; const entryFile = nestCli.entryFile ?? "main"; const outDir = await readTsconfigOutDir(this.#appPath, signal); const relativeEntry = path.posix.join( path.posix.relative(".", sourceRoot) || ".", `${entryFile}.js`, ); return joinPosix(outDir, relativeEntry); } } interface NestCliConfig { sourceRoot?: string; entryFile?: string; } async function readNestCliConfig( appPath: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); let content: string; try { content = await readFile(path.join(appPath, NEST_CLI_FILENAME), { encoding: "utf-8", signal, }); } catch (error) { if (signal?.aborted) throw error; return {}; } try { const parsed = JSON.parse(content) as { sourceRoot?: unknown; entryFile?: unknown; }; return { sourceRoot: typeof parsed.sourceRoot === "string" ? parsed.sourceRoot : undefined, entryFile: typeof parsed.entryFile === "string" ? parsed.entryFile : undefined, }; } catch { return {}; } } const TSCONFIG_FILENAMES = ["tsconfig.build.json", "tsconfig.json"] as const; /** Best-effort read of `compilerOptions.outDir`, defaulting to `dist`. */ async function readTsconfigOutDir( appPath: string, signal?: AbortSignal, ): Promise { for (const fileName of TSCONFIG_FILENAMES) { signal?.throwIfAborted(); let content: string; try { content = await readFile(path.join(appPath, fileName), { encoding: "utf-8", signal, }); } catch (error) { if (signal?.aborted) throw error; continue; } const outDir = parseTsconfigOutDir(content); if (outDir) { return outDir; } } return "dist"; } /** Reads `compilerOptions.outDir` from a tsconfig source, tolerating comments. */ function parseTsconfigOutDir(content: string): string | null { try { const parsed = JSON.parse(stripJsonComments(content)) as { compilerOptions?: { outDir?: unknown }; }; const outDir = parsed.compilerOptions?.outDir; if (typeof outDir !== "string") { return null; } return normalizeRelative(outDir); } catch { return null; } } /** * Stages the compiled entrypoint and its transitive dependencies into * `artifactDir` by tracing with `@vercel/nft`, then copying only the traced * paths. Returns the artifact-root-relative entrypoint to run with `node`. * * The trace base is the source root, not the app dir, because nft ignores * everything above `base`: in a workspace, deps hoisted to the repo root sit * above the app and would be dropped, leaving the artifact with no usable * `node_modules`. Tracing from the source root keeps the workspace-relative * layout (e.g. `apps/api/dist/main.js` plus root and app-local `node_modules`), * so `node /` resolves deps by walking up. For a single-app * repo the source root equals the app dir, so the entrypoint stays `dist/main.js`. * * The trace lists both the visible `node_modules/` symlinks and the real * files they resolve to (e.g. pnpm `.pnpm/` / bun `.bun/` stores), so * symlinks are recreated verbatim and the store files are copied alongside. */ async function stageTracedArtifact(options: { appPath: string; artifactDir: string; compiledEntry: string; signal?: AbortSignal; }): Promise { const appPath = path.resolve(options.appPath); const sourceRoot = await resolveSourceRoot(appPath, options.signal); const entry = path.join(appPath, options.compiledEntry); const entrypoint = path.posix.normalize( path.relative(sourceRoot, entry).split(path.sep).join("/"), ); options.signal?.throwIfAborted(); const { fileList } = await nodeFileTrace([entry], { base: sourceRoot }); await mkdir(options.artifactDir, { recursive: true }); for (const relativePath of fileList) { options.signal?.throwIfAborted(); await stageTracedPath( path.join(sourceRoot, relativePath), path.join(options.artifactDir, relativePath), ); } return entrypoint; } /** Copies one traced path, preserving symlinks and file mode. */ async function stageTracedPath( sourcePath: string, destinationPath: string, ): Promise { const info = await lstat(sourcePath).catch(() => null); if (!info) { return; } await mkdir(path.dirname(destinationPath), { recursive: true }); if (info.isSymbolicLink()) { await symlink(await readlink(sourcePath), destinationPath); return; } if (info.isFile()) { await copyFile(sourcePath, destinationPath); await chmod(destinationPath, info.mode); } } function normalizeRelative(value: string): string | null { const raw = value.trim().replace(/\\/g, "/"); if (raw.length === 0) { return null; } const normalized = path.posix.normalize(raw); if ( path.posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../") ) { return null; } return normalized === "." ? null : normalized; } /** * Removes `//` line comments and block comments from JSONC, tracking * string state so a `//` inside a string value (e.g. `"https://..."`) is * preserved. A naive regex strips such content and mis-reads the surrounding * keys; on any malformed input the caller falls back to the default outDir. */ function stripJsonComments(content: string): string { let result = ""; let inString = false; let escaped = false; for (let i = 0; i < content.length; i++) { const char = content[i]; if (inString) { result += char; if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; result += char; continue; } const next = content[i + 1]; if (char === "/" && next === "/") { while (i < content.length && content[i] !== "\n") { i++; } if (i < content.length) { result += content[i]; } continue; } if (char === "/" && next === "*") { i += 2; while ( i < content.length && !(content[i] === "*" && content[i + 1] === "/") ) { i++; } i++; continue; } result += char; } return result; } async function isFile(targetPath: string): Promise { const info = await stat(targetPath).catch(() => null); return info?.isFile() ?? false; }