import {cp, lstat, mkdir, readFile, realpath, rm, writeFile} from 'node:fs/promises'; import {basename, join, relative, resolve, sep} from 'node:path'; import {fileURLToPath} from 'node:url'; import {copyReleaseTree, under} from './copy-release-tree.mjs'; const projectRoot=fileURLToPath(new URL('../',import.meta.url)); /** Assemble a relocatable release; only the selected public environment profile is shipped. */ export async function packageStandalone(environment,{root=projectRoot}={}) { if(!['test','production'].includes(environment))throw new Error('Usage: node scripts/package-standalone.mjs test|production'); root=await realpath(root); const build=environment==='test'?'.next-test':'.next'; const dist=join(root,'dist'),destination=join(dist,environment),source=join(root,build,'standalone'); async function directory(path,optional=false) { const info=await lstat(path).catch(error=>{if(optional&&error.code==='ENOENT')return null;throw error;}); if(info&&(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path))throw new Error(`Unsafe release directory: ${path}`); } for(const path of [join(root,build),source])await directory(path); for(const file of [join(source,'server.js'),join(source,build,'BUILD_ID'),join(root,'config',environment+'.env')]) { if(!(await lstat(file)).isFile()||!(await readFile(file)).length)throw new Error(`Missing release file: ${file}`); } await directory(dist,true);await mkdir(dist,{recursive:true});await directory(destination,true); // A fixed dist/ is the only location removed by this command. await rm(destination,{recursive:true,force:true}); const filter=async path=>{ const name=basename(path); if(name.startsWith('.env')||name.endsWith('.local.env')||name.startsWith('._')||name==='.DS_Store'||name.endsWith('.log'))return false; // Dynamic filesystem reads can make Next trace sibling build/cache/test directories. // Keep this project's runtime roots, never recursively include earlier dist releases. if(path.startsWith(source+sep)&&!['server.js','package.json','package-lock.json',build,'node_modules','src'].includes(relative(source,path).split(sep)[0]))return false; if(path===join(source,build,'cache'))return false; // npm installs a flat tree; pnpm/Next standalone keeps relative in-tree links. // copyReleaseTree rewrites safe links; reject only targets that escape the project root. if((await lstat(path)).isSymbolicLink()){ const target=await realpath(path).catch(()=>null); if(!target||!under(target,root))throw new Error(`Release source contains a symbolic link: ${path}`); } return true; }; await copyReleaseTree(source,destination,{root,filter}); // public/static are plain trees; dotenv is pnpm-linked — ship a concrete copy for runtime helpers. for(const [from,to] of [[join(root,'public'),'public'],[join(root,build,'static'),build+'/static'], [join(root,'node_modules/dotenv'),'node_modules/dotenv']]) { await cp(await realpath(from),join(destination,to),{recursive:true,dereference:true,filter}); } await mkdir(join(destination,'config'),{recursive:true});await mkdir(join(destination,'scripts'),{recursive:true}); for(const file of ['package.json','package-lock.json','scripts/next-env.mjs','scripts/check-health.mjs','config/'+environment+'.env']) { await cp(join(root,file),join(destination,file)); } await writeFile(join(destination,'start.mjs'),`import {fileURLToPath} from 'node:url'; import {nextCommand} from './scripts/next-env.mjs'; const root=fileURLToPath(new URL('./',import.meta.url)); process.chdir(root); const {env}=nextCommand(${JSON.stringify(environment)},'start',[],{root}); Object.assign(process.env,env); process.env.HOSTNAME=process.env.HOSTNAME||'0.0.0.0'; await import('./server.js'); `); console.log(`Standalone release ready: ${destination}`); return destination; } if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)) { try { if(process.argv.length!==3)throw new Error('Usage: node scripts/package-standalone.mjs test|production'); await packageStandalone(process.argv[2]); }catch(error){console.error(error.message);process.exitCode=1;} }