Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-11-05 22:26:18 +07:00
co-authored by Claude Opus 5.5
commit 6fb2b1e486
385 changed files with 48049 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
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`);