@@ -0,0 +1,246 @@
|
||||
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-1048-hddj-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 { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from 'dotenv';
|
||||
|
||||
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") {
|
||||
await cp(resolve("package.json"), resolve(destination, "package.json"));
|
||||
await cp(resolve("package-lock.json"), 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: "hddj_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`);
|
||||
Reference in New Issue
Block a user