import { createRequire } from "node:module"; import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile, } from "node:fs/promises"; import { dirname, join, 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"); } /** 本地构建目录(test/dev 分目录,避免互相覆盖) */ const buildDirectory = environment === "production" ? ".next" : environment === "test" ? ".next-test" : ".next-dev"; /** 测试包保留 .next-test,与 1047 测试服 run.sh 一致;正式包统一为 .next */ const runtimeDirectory = environment === "test" ? buildDirectory : ".next"; const destination = resolve("dist", environment); const require = createRequire(import.meta.url); /** 将包内 .next-test / .next-dev 路径改写为 .next(不含 node_modules) */ async function rewriteRuntimeDistDir(root: string, from: string, to: string) { if (from === to) return; const skip = new Set(["node_modules"]); const walk = async (dir: string) => { for (const entry of await readdir(dir, { withFileTypes: true })) { if (skip.has(entry.name)) continue; const path = join(dir, entry.name); if (entry.isDirectory()) { await walk(path); continue; } if (!/\.(js|mjs|cjs|json|map)$/.test(entry.name)) continue; const text = await readFile(path, "utf8"); if (!text.includes(from)) continue; await writeFile(path, text.split(from).join(to)); } }; await walk(root); } 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 runtimePort = (config.PORT || "3000").trim() || "3000"; 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"); } } const buildId = (await readFile(resolve(buildDirectory, "BUILD_ID"), "utf8")).trim(); if (!buildId) throw new Error(`${buildDirectory}/BUILD_ID 为空,请先 pnpm build`); await rm(destination, { recursive: true, force: true }); await mkdir(destination, { recursive: true }); await cp(resolve(buildDirectory, "standalone"), destination, { recursive: true }); await cp(resolve("public"), resolve(destination, "public"), { recursive: true }); await cp( resolve(buildDirectory, "static"), resolve(destination, buildDirectory, "static"), { recursive: true }, ); // 测试/开发构建:把包内目录规范成 .next,并改写 server.js / chunks 中的 distDir if (buildDirectory !== runtimeDirectory) { const fromDir = resolve(destination, buildDirectory); const toDir = resolve(destination, runtimeDirectory); await rm(toDir, { recursive: true, force: true }); await rename(fromDir, toDir); await rewriteRuntimeDistDir(destination, buildDirectory, runtimeDirectory); } // 将 standalone server.js 默认端口改为当前环境 PORT(Next 生成时写死 3000) { const serverJsPath = resolve(destination, "server.js"); const serverJs = await readFile(serverJsPath, "utf8"); const nextDefaultPort = /const currentPort = parseInt\(process\.env\.PORT, 10\) \|\| 3000/; if (!nextDefaultPort.test(serverJs)) { throw new Error( "server.js 未找到默认端口写法 `parseInt(process.env.PORT, 10) || 3000`,请检查 Next standalone 产物", ); } await writeFile( serverJsPath, serverJs.replace( nextDefaultPort, `const currentPort = parseInt(process.env.PORT, 10) || ${runtimePort}`, ), ); } await mkdir(resolve(destination, "config"), { recursive: true }); await cp( resolve("config", `${environment}.env`), resolve(destination, "config", `${environment}.env`), ); if (environment !== "test") { const dotenvRoot = dirname(require.resolve("dotenv/package.json")); await mkdir(resolve(destination, "node_modules"), { recursive: true }); await cp(dotenvRoot, resolve(destination, "node_modules/dotenv"), { recursive: true, }); } // 运行包 package.json:禁止 next start,必须走 standalone server.js await writeFile( resolve(destination, "package.json"), `${JSON.stringify( { name: "jha-1049-sgdj-web", version: "1.0.0", private: true, type: "module", scripts: { start: "node ./start.mjs", }, engines: { node: ">=20.9.0" }, }, null, 2, )}\n`, ); await writeFile( resolve(destination, "start.mjs"), `import { readFileSync, existsSync } from 'node:fs'; import { setDefaultResultOrder } from 'node:dns'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from 'dotenv'; setDefaultResultOrder('ipv4first'); const root = dirname(fileURLToPath(import.meta.url)); process.chdir(root); if (existsSync(resolve('static'))) { throw new Error('Standalone must run in an isolated directory without a legacy static/ folder'); } const buildIdFile = resolve(${JSON.stringify(runtimeDirectory)}, 'BUILD_ID'); if (!existsSync(buildIdFile)) { throw new Error( '缺少 ' + ${JSON.stringify(runtimeDirectory)} + '/BUILD_ID。请使用打包产物并用 node start.mjs 启动,不要使用 next start。', ); } 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', }; const api = (config.API_PROXY_TARGET || config.NEXT_PUBLIC_API_BASE_URL || '').trim(); if (!api) throw new Error('API_PROXY_TARGET 或 NEXT_PUBLIC_API_BASE_URL 必填'); Object.assign(process.env, config); // standalone 入口(不是 next start) await import('./server.js'); `, ); if (environment === "test") { const lockfile = resolve("package-lock.json"); if (!(await readFile(lockfile).catch(() => Buffer.alloc(0))).length) { throw new Error("测试包需要 package-lock.json,供服务器 npm ci --omit=dev"); } await cp(resolve("package.json"), resolve(destination, "package.json")); await cp(lockfile, resolve(destination, "package-lock.json")); await mkdir(resolve(destination, "scripts"), { recursive: true }); await cp(resolve("scripts/next-env.mjs"), resolve(destination, "scripts/next-env.mjs")); await cp( resolve("scripts/check-health.mjs"), resolve(destination, "scripts/check-health.mjs"), ); } await writeFile( resolve(destination, "ecosystem.config.cjs"), `/** PM2:必须 node start.mjs,禁止 next start */ module.exports = { apps: [ { name: "sgdj_h5", cwd: __dirname, script: "start.mjs", interpreter: "node", instances: 1, exec_mode: "fork", env: { NODE_ENV: "production", APP_ENV: ${JSON.stringify(environment)}, HOSTNAME: "0.0.0.0", PORT: ${JSON.stringify(runtimePort)}, }, }, ], }; `, ); const packagedBuildId = ( await readFile(resolve(destination, runtimeDirectory, "BUILD_ID"), "utf8") ).trim(); if (packagedBuildId !== buildId) { throw new Error(`打包后 BUILD_ID 不一致:build=${buildId} packaged=${packagedBuildId}`); } const api = config.API_PROXY_TARGET?.trim() || config.NEXT_PUBLIC_API_BASE_URL?.trim(); if (!api) { console.warn( `[package-standalone] 警告:当前 ${environment} 未配置 API_PROXY_TARGET / NEXT_PUBLIC_API_BASE_URL`, ); } console.log(`Standalone package ready: ${destination}`); console.log(`BUILD_ID=${packagedBuildId}`); console.log(`PORT=${runtimePort}`); console.log(`启动:cd ${destination} && node start.mjs`); console.log(`或:pm2 start ${destination}/ecosystem.config.cjs`);