233 lines
7.5 KiB
TypeScript
233 lines
7.5 KiB
TypeScript
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,便于服务器 start.mjs / run.sh */
|
||
const runtimeDirectory = ".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 });
|
||
// verbatimSymlinks:保留 pnpm 的相对软链;默认 cp 会改写成本机绝对路径,上传到服务器后软链失效
|
||
await cp(resolve(buildDirectory, "standalone"), destination, { recursive: true, verbatimSymlinks: 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`),
|
||
);
|
||
|
||
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-1046-hjsq-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');
|
||
`,
|
||
);
|
||
|
||
await writeFile(
|
||
resolve(destination, "ecosystem.config.cjs"),
|
||
`/** PM2:必须 node start.mjs,禁止 next start */
|
||
module.exports = {
|
||
apps: [
|
||
{
|
||
name: "hjsq_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`);
|