import { cp, lstat, mkdir, readFile, readdir, realpath, rm, unlink, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; import { parse } from 'dotenv'; const environment = process.argv[2] || 'production'; if (!['development', 'test', 'production'].includes(environment)) throw new Error('Unknown environment'); const buildDirectory = environment === 'production' ? '.next' : environment === 'test' ? '.next-test' : '.next-dev'; const destination = resolve('dist', environment); const readConfig = async (file: string) => parse(await readFile(file).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return Buffer.alloc(0); throw error; })); const config = { ...await readConfig(resolve('config', environment + '.env')), ...await readConfig(resolve('config', environment + '.local.env')), ...process.env }; const sitemapDirectory = resolve(config.SITEMAP_DIR?.trim() || './template/pages/sitemaps'); const inside = (base: string, target: string) => { const suffix = relative(base, target); return suffix === '' || (suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix)); }; // dist is replaceable build output. Never let packaging remove a backend writer's directory. const canonicalSitemapDirectory = await realpath(sitemapDirectory).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return sitemapDirectory; throw error; }); if (inside(destination, sitemapDirectory) || inside(destination, canonicalSitemapDirectory)) throw new Error('SITEMAP_DIR must live outside the replaceable standalone package directory'); for (const directory of [resolve('dist'), destination]) { const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; }); if (info && (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory)) throw new Error('Standalone output must be an ordinary build directory'); } await readFile(resolve(buildDirectory, 'BUILD_ID')); await rm(destination, { recursive: true, force: true }); await mkdir(destination, { recursive: true }); await cp(resolve(buildDirectory, 'standalone'), destination, { recursive: true }); // Next 16.4 may copy Node Proxy's trace without applying route tracing exclusions. // Remove generated XML only from this copied release, never through a backend mount. const copiedSitemaps: string[] = []; const ordinaryDirectory = async (suffix: string) => { let directory = destination; for (const segment of ['', ...suffix.split(/[\\/]/).filter(Boolean)]) { directory = resolve(directory, segment); const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; }); if (!info) return false; if (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory) throw new Error('Sitemap cleanup requires ordinary directories inside the standalone package'); } return true; }; const stageRemoval = async (file: string) => { const info = await lstat(file).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; }); if (!info) return; if (!info.isFile() && !info.isSymbolicLink()) throw new Error('Packaged sitemap XML must be a file, not a directory or special device'); copiedSitemaps.push(file); }; for (const suffix of ['template/pages/sitemaps', 'data/sitemaps']) { if (!await ordinaryDirectory(suffix)) continue; const directory = resolve(destination, suffix); for (const name of await readdir(directory)) { if (name === 'sitemap.xml' || /^sitemap-[a-z0-9-]+\.xml$/.test(name)) await stageRemoval(resolve(directory, name)); } } const fallback = resolve(destination, 'template/pages/sitemap.xml'); if (await ordinaryDirectory(relative(destination, dirname(fallback)))) await stageRemoval(fallback); // unlink does not follow file symlinks and cannot recursively remove a directory. for (const file of copiedSitemaps) await unlink(file); await cp(resolve('public'), resolve(destination, 'public'), { recursive: true }); await cp(resolve(buildDirectory, 'static'), resolve(destination, buildDirectory, 'static'), { recursive: true }); await mkdir(resolve(destination, 'config'), { recursive: true }); await cp(resolve('config', environment + '.env'), resolve(destination, 'config', environment + '.env')); await cp(resolve('node_modules/dotenv'), resolve(destination, 'node_modules/dotenv'), { recursive: true }); await writeFile(resolve(destination, 'start.mjs'), `import { readFileSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from 'dotenv'; process.chdir(dirname(fileURLToPath(import.meta.url))); // Next checks the deprecated root static/ before public/static/. An old Go // deployment in this directory would silently override the bundled assets. if (existsSync(resolve('static'))) throw new Error('Standalone must run in an isolated directory without a legacy static/ folder; it overrides public/static/.'); const environment = ${JSON.stringify(environment)}; const read = file => existsSync(file) ? parse(readFileSync(file)) : {}; const config = { ...read(resolve('config', environment + '.env')), ...read(resolve('config', environment + '.local.env')), ...process.env, APP_ENV: environment, NODE_ENV: 'production' }; if (!config.UPSTREAM_API_URL?.trim()) throw new Error('UPSTREAM_API_URL is required'); Object.assign(process.env, config); await import('./server.js'); `); console.log(`Standalone package ready: ${destination}; run node start.mjs inside it.`);