import { chmod, copyFile, lstat, mkdir, readdir, readlink, stat, } from "node:fs/promises"; import path from "node:path"; import { resolveSourceRoot } from "./config/source-root.ts"; /** * Stages a framework's standalone output (e.g. Next.js `.next/standalone`) * into a deployable artifact directory, materializing the symlinks the output * carries into the workspace `node_modules` and hoisting isolated-store * dependencies (pnpm `.pnpm`, bun `.bun`) so Node resolution works once the * artifact is unpacked elsewhere. * * Location-agnostic: the workspace boundary is resolved from `appPath`, not * the current working directory. */ export async function stageStandaloneArtifact(options: { standaloneDir: string; artifactDir: string; appPath: string; signal?: AbortSignal; }): Promise { const standaloneRoot = path.resolve(options.standaloneDir); const artifactRoot = path.resolve(options.artifactDir); const appRoot = path.resolve(options.appPath); const sourceRoot = await resolveSourceRoot(appRoot, options.signal); await copyPathMaterializingSymlinks(standaloneRoot, artifactRoot, { standaloneRoot, appRoot, sourceRoot, signal: options.signal, }); await hoistIsolatedStoreDependencies( path.join(artifactRoot, "node_modules"), options.signal, ); } /** Options shared by the symlink-materializing copy walk. */ interface MaterializeOptions { /** Root of the standalone output being copied (for fallback resolution). */ standaloneRoot: string; /** The app directory; symlink targets here are materialized. */ appRoot: string; /** The workspace/source root; targets in its node_modules are materialized. */ sourceRoot: string; signal?: AbortSignal; } /** * pnpm and bun (isolated linker) both keep packages in a virtual store with a * shared symlink farm (`.pnpm/node_modules`, `.bun/node_modules`). Hoist the * farm entries to the artifact's `node_modules` root so Node-style resolution * works after symlinks are materialized. */ export async function hoistIsolatedStoreDependencies( nodeModulesDir: string, signal?: AbortSignal, ): Promise { await hoistStoreDependencies( nodeModulesDir, path.join(nodeModulesDir, ".pnpm", "node_modules"), signal, ); await hoistStoreDependencies( nodeModulesDir, path.join(nodeModulesDir, ".bun", "node_modules"), signal, ); } async function hoistStoreDependencies( nodeModulesDir: string, storeNodeModulesDir: string, signal?: AbortSignal, ): Promise { if (!(await directoryExists(storeNodeModulesDir, signal))) { return; } const entries = await readEntries(storeNodeModulesDir, signal); for (const entry of entries) { const sourcePath = path.join(storeNodeModulesDir, entry.name); if (entry.name.startsWith("@") && entry.isDirectory()) { const scopedEntries = await readEntries(sourcePath, signal); for (const scopedEntry of scopedEntries) { const scopedDestination = path.join( nodeModulesDir, entry.name, scopedEntry.name, ); if (await pathExists(scopedDestination, signal)) { continue; } signal?.throwIfAborted(); await mkdir(path.dirname(scopedDestination), { recursive: true }); await copyPathMaterializingSymlinks( path.join(sourcePath, scopedEntry.name), scopedDestination, storeMaterializeOptions(storeNodeModulesDir, nodeModulesDir, signal), ); } continue; } const destinationPath = path.join(nodeModulesDir, entry.name); if (await pathExists(destinationPath, signal)) { continue; } await copyPathMaterializingSymlinks( sourcePath, destinationPath, storeMaterializeOptions(storeNodeModulesDir, nodeModulesDir, signal), ); } } function storeMaterializeOptions( storeNodeModulesDir: string, nodeModulesDir: string, signal?: AbortSignal, ): MaterializeOptions { return { standaloneRoot: storeNodeModulesDir, appRoot: nodeModulesDir, sourceRoot: nodeModulesDir, signal, }; } async function copyPathMaterializingSymlinks( sourcePath: string, destinationPath: string, options: MaterializeOptions, ): Promise { options.signal?.throwIfAborted(); const sourceStat = await lstat(sourcePath); if (sourceStat.isSymbolicLink()) { const resolvedTarget = await resolveSymlinkTarget(sourcePath, options); if (resolvedTarget === null) { return; } await copyPathMaterializingSymlinks( resolvedTarget, destinationPath, options, ); return; } if (sourceStat.isDirectory()) { await mkdir(destinationPath, { recursive: true }); const entries = await readEntries(sourcePath, options.signal); for (const entry of entries) { await copyPathMaterializingSymlinks( path.join(sourcePath, entry.name), path.join(destinationPath, entry.name), options, ); } return; } if (sourceStat.isFile()) { await mkdir(path.dirname(destinationPath), { recursive: true }); await copyFile(sourcePath, destinationPath); await chmod(destinationPath, sourceStat.mode); } } async function resolveSymlinkTarget( symlinkPath: string, options: MaterializeOptions, ): Promise { options.signal?.throwIfAborted(); const linkTarget = await readlink(symlinkPath); const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget); if (await pathExists(resolvedTarget, options.signal)) { if ( !isPathWithin(options.appRoot, resolvedTarget) && !isPathWithinWorkspaceDependency(options.sourceRoot, resolvedTarget) ) { throw new Error( `Build artifact symlink escapes the app directory: ${resolvedTarget}`, ); } return resolvedTarget; } if (isPathWithin(options.standaloneRoot, resolvedTarget)) { const fallbackTarget = path.join( options.appRoot, path.relative(options.standaloneRoot, resolvedTarget), ); if (await pathExists(fallbackTarget, options.signal)) { return fallbackTarget; } } // pnpm's hoist layer (.pnpm/node_modules/*) contains speculative links that // are routinely dangling — Next's tracer doesn't always align with what pnpm // populated. Drop these silently. Dangling links elsewhere still throw so a // real missing dep doesn't get masked. if (isPnpmHoistLink(symlinkPath)) { return null; } throw new Error( `Standalone symlink target is missing: ${symlinkPath} -> ${linkTarget} (resolved to ${resolvedTarget})`, ); } function isPnpmHoistLink(symlinkPath: string): boolean { const parts = path.dirname(symlinkPath).split(path.sep); for (let i = 0; i < parts.length - 1; i++) { if (parts[i] === ".pnpm" && parts[i + 1] === "node_modules") { return true; } } return false; } function isPathWithin(rootPath: string, candidatePath: string): boolean { const relativePath = path.relative(rootPath, candidatePath); return ( relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath)) ); } function isPathWithinWorkspaceDependency( sourceRoot: string, candidatePath: string, ): boolean { return isPathWithin(path.join(sourceRoot, "node_modules"), candidatePath); } async function readEntries(directory: string, signal?: AbortSignal) { signal?.throwIfAborted(); return readdir(directory, { withFileTypes: true }); } async function pathExists( targetPath: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); try { await stat(targetPath); return true; } catch { return false; } } async function directoryExists( targetPath: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); try { return (await stat(targetPath)).isDirectory(); } catch { return false; } }