Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parse } from "dotenv";
const [environment = "development", command = "dev", ...args] = process.argv.slice(2);
if (
!["development", "test", "production"].includes(environment) ||
!["dev", "build", "start"].includes(command)
) {
throw new Error(
"Usage: tsx scripts/next-env.ts development|test|production dev|build|start",
);
}
const config = (file: string) => (existsSync(file) ? parse(readFileSync(file)) : {});
const childEnv: NodeJS.ProcessEnv = {
...config(resolve(`config/${environment}.env`)),
...config(resolve(`config/${environment}.local.env`)),
...process.env,
APP_ENV: environment,
NODE_ENV: command === "dev" ? "development" : "production",
};
const nodeOptions = [childEnv.NODE_OPTIONS, "--dns-result-order=ipv4first"]
.filter(Boolean)
.join(" ");
childEnv.NODE_OPTIONS = nodeOptions;
if (command !== "build") {
const api =
childEnv.API_PROXY_TARGET?.trim() || childEnv.NEXT_PUBLIC_API_BASE_URL?.trim();
if (!api) {
throw new Error(
`API_PROXY_TARGET 或 NEXT_PUBLIC_API_BASE_URL 必填(${environment})。可写在 config/${environment}.local.env`,
);
}
}
const nextBin = resolve("node_modules/next/dist/bin/next");
const child = spawn(
process.execPath,
[
nextBin,
command,
...(command === "build" ? [] : ["--port", childEnv.PORT || "3000"]),
...args,
],
{ stdio: "inherit", env: childEnv },
);
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => child.kill(signal));
}
child.on("exit", (code) => process.exit(code ?? 1));
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# 正式服一键:build → standalone → Linux sharp → web_hjsq_h5.tar.gz → scp → rsupdate.sh
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")/.." && pwd)
cd "$SCRIPT_DIR"
TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}"
TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}"
PACKAGE_NAME=web_hjsq_h5.tar.gz
MD5_NAME=web_hjsq_h5.md5
RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC"
JUMP_HOST="${DEPLOY_JUMP_HOST:-server@34.92.204.175}"
JUMP_DIR="${DEPLOY_JUMP_DIR:-/home/server/web_hjsq/h5}"
SCP_PORT="${DEPLOY_SCP_PORT:-22889}"
SSH_PORT="${DEPLOY_SSH_PORT:-22889}"
REMOTE_SCRIPT="${DEPLOY_REMOTE_SCRIPT:-rsupdate.sh}"
SSH_IDENTITY="${DEPLOY_SSH_IDENTITY:-$HOME/.ssh/id_ed25519}"
PACKAGE_ONLY=0
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
step() { printf '\n▸ %s\n' "$*"; }
for argument in "$@"; do
case "$argument" in
--package-only) PACKAGE_ONLY=1 ;;
-h|--help)
printf '用法:%s [--package-only]\n' "$0"
printf '默认:打包并 scp + 执行远程 %s\n' "$REMOTE_SCRIPT"
printf '可选环境变量:DEPLOY_JUMP_HOST / DEPLOY_JUMP_DIR / DEPLOY_SCP_PORT / DEPLOY_SSH_PORT\n'
printf ' DEPLOY_REMOTE_SCRIPT / DEPLOY_SSH_IDENTITY / DEPLOY_TARGET_ARCH / DEPLOY_TARGET_LIBC\n'
exit 0 ;;
*) fail "不支持的参数 $argument;使用 --help 查看用法" ;;
esac
done
JUMP_DIR="${JUMP_DIR%/}"
command -v pnpm >/dev/null || fail '缺少 pnpm'
node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || a===20 && b<9) process.exit(1)' \
|| fail '需要 Node.js >=20.9'
if [ "$PACKAGE_ONLY" = 0 ]; then
for tool in ssh scp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
[ -n "$JUMP_HOST" ] || fail '请设置 DEPLOY_JUMP_HOST'
[ -f "$SSH_IDENTITY" ] || fail "缺少 SSH 私钥:$SSH_IDENTITY(可用 DEPLOY_SSH_IDENTITY 指定)"
fi
step '生产构建'
pnpm run build
step '整理 standalone'
pnpm run bundle
step "补 Linux $TARGET_ARCH/$TARGET_LIBC sharp"
pnpm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC"
for file in start.mjs server.js .next/BUILD_ID config/production.env ecosystem.config.cjs; do
[ -s "$RELEASE/$file" ] || fail "运行包缺少 $file"
done
[ -d "$RELEASE/.next/static" ] && [ -d "$RELEASE/public" ] || fail '运行包缺少静态资源'
step "打包 $PACKAGE_NAME"
rm -f "$SCRIPT_DIR/$PACKAGE_NAME" "$SCRIPT_DIR/$MD5_NAME"
COPYFILE_DISABLE=1 COPY_EXTENDED_ATTRIBUTES_DISABLE=1 tar \
--no-xattrs \
--exclude='._*' --exclude='.DS_Store' --exclude='.env*' \
--exclude='*.local.env' --exclude='*.log' \
-czf "$SCRIPT_DIR/$PACKAGE_NAME" -C "$RELEASE" .
chmod 600 "$SCRIPT_DIR/$PACKAGE_NAME"
if command -v md5sum >/dev/null; then
md5sum "$SCRIPT_DIR/$PACKAGE_NAME" | awk '{print $1}' > "$SCRIPT_DIR/$MD5_NAME"
else
md5 -q "$SCRIPT_DIR/$PACKAGE_NAME" > "$SCRIPT_DIR/$MD5_NAME"
fi
printf '\n完成:%s(%s)\n' "$PACKAGE_NAME" "$(du -h "$SCRIPT_DIR/$PACKAGE_NAME" | awk '{print $1}')"
printf 'MD5:%s\n' "$(cat "$SCRIPT_DIR/$MD5_NAME")"
if [ "$PACKAGE_ONLY" = 1 ]; then
printf '已跳过上传(--package-only)。\n'
exit 0
fi
SSH_OPTS=(-i "$SSH_IDENTITY" -o IdentitiesOnly=yes -o BatchMode=yes)
step "上传 $JUMP_HOST:$JUMP_DIR/"
scp -P "$SCP_PORT" "${SSH_OPTS[@]}" \
"$SCRIPT_DIR/$PACKAGE_NAME" \
"$JUMP_HOST:$JUMP_DIR/"
step "执行远程 $JUMP_DIR/$REMOTE_SCRIPT"
ssh -p "$SSH_PORT" "${SSH_OPTS[@]}" \
"$JUMP_HOST" "bash $JUMP_DIR/$REMOTE_SCRIPT"
printf '\n打包并推送完成。\n'
printf '若远程解压报 File exists:先在服务器清理旧 node_modules 后再跑 %s。\n' "$REMOTE_SCRIPT"
+231
View File
@@ -0,0 +1,231 @@
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 });
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`),
);
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`);
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# 测试服一键:build:test → standalone → Linux sharp → web_hjsq_h5.tar.gz → scp → run.sh
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")/.." && pwd)
cd "$SCRIPT_DIR"
TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}"
TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}"
PACKAGE_NAME=web_hjsq_h5.tar.gz
MD5_NAME=web_hjsq_h5.md5
RELEASE="$SCRIPT_DIR/dist/test-linux-$TARGET_ARCH-$TARGET_LIBC"
JUMP_HOST="${DEPLOY_TEST_HOST:-root@172.104.181.99}"
JUMP_DIR="${DEPLOY_TEST_DIR:-/server/web_hjsq/h5}"
SCP_PORT="${DEPLOY_SCP_PORT:-22889}"
SSH_PORT="${DEPLOY_SSH_PORT:-22889}"
REMOTE_SCRIPT="${DEPLOY_TEST_REMOTE_SCRIPT:-run.sh}"
SSH_IDENTITY="${DEPLOY_SSH_IDENTITY:-$HOME/.ssh/id_ed25519}"
PACKAGE_ONLY=0
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
step() { printf '\n▸ %s\n' "$*"; }
for argument in "$@"; do
case "$argument" in
--package-only) PACKAGE_ONLY=1 ;;
-h|--help)
printf '用法:%s [--package-only]\n' "$0"
printf '默认:测试打包并 scp + cd 远程目录执行 %s\n' "$REMOTE_SCRIPT"
printf '可选:DEPLOY_TEST_HOST / DEPLOY_TEST_DIR / DEPLOY_SCP_PORT / DEPLOY_SSH_PORT\n'
printf ' DEPLOY_TEST_REMOTE_SCRIPT / DEPLOY_SSH_IDENTITY / DEPLOY_TARGET_ARCH / DEPLOY_TARGET_LIBC\n'
exit 0 ;;
*) fail "不支持的参数 $argument;使用 --help 查看用法" ;;
esac
done
JUMP_DIR="${JUMP_DIR%/}"
command -v pnpm >/dev/null || fail '缺少 pnpm'
node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || a===20 && b<9) process.exit(1)' \
|| fail '需要 Node.js >=20.9'
[ -s config/test.env ] || fail '缺少 config/test.env'
if [ "$PACKAGE_ONLY" = 0 ]; then
for tool in ssh scp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
[ -n "$JUMP_HOST" ] || fail '请设置 DEPLOY_TEST_HOST'
[ -f "$SSH_IDENTITY" ] || fail "缺少 SSH 私钥:$SSH_IDENTITY(可用 DEPLOY_SSH_IDENTITY 指定)"
fi
step '测试环境构建'
pnpm run build:test
step '整理 standalone'
pnpm run bundle:test
step "补 Linux $TARGET_ARCH/$TARGET_LIBC sharp(test)"
pnpm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC" test
for file in start.mjs server.js .next/BUILD_ID config/test.env ecosystem.config.cjs; do
[ -s "$RELEASE/$file" ] || fail "运行包缺少 $file"
done
[ -d "$RELEASE/.next/static" ] && [ -d "$RELEASE/public" ] || fail '运行包缺少静态资源'
step "打包 $PACKAGE_NAME"
rm -f "$SCRIPT_DIR/$PACKAGE_NAME" "$SCRIPT_DIR/$MD5_NAME"
COPYFILE_DISABLE=1 COPY_EXTENDED_ATTRIBUTES_DISABLE=1 tar \
--no-xattrs \
--exclude='._*' --exclude='.DS_Store' --exclude='.env*' \
--exclude='*.local.env' --exclude='*.log' \
-czf "$SCRIPT_DIR/$PACKAGE_NAME" -C "$RELEASE" .
chmod 600 "$SCRIPT_DIR/$PACKAGE_NAME"
if command -v md5sum >/dev/null; then
md5sum "$SCRIPT_DIR/$PACKAGE_NAME" | awk '{print $1}' > "$SCRIPT_DIR/$MD5_NAME"
else
md5 -q "$SCRIPT_DIR/$PACKAGE_NAME" > "$SCRIPT_DIR/$MD5_NAME"
fi
printf '\n完成:%s(%s)\n' "$PACKAGE_NAME" "$(du -h "$SCRIPT_DIR/$PACKAGE_NAME" | awk '{print $1}')"
printf 'MD5:%s\n' "$(cat "$SCRIPT_DIR/$MD5_NAME")"
if [ "$PACKAGE_ONLY" = 1 ]; then
printf '已跳过上传(--package-only)。\n'
exit 0
fi
SSH_OPTS=(-i "$SSH_IDENTITY" -o IdentitiesOnly=yes -o BatchMode=yes)
step "上传 $JUMP_HOST:$JUMP_DIR/"
scp -P "$SCP_PORT" "${SSH_OPTS[@]}" \
"$SCRIPT_DIR/$PACKAGE_NAME" \
"$JUMP_HOST:$JUMP_DIR/"
step "执行远程 cd $JUMP_DIR && bash $REMOTE_SCRIPT"
ssh -p "$SSH_PORT" "${SSH_OPTS[@]}" \
"$JUMP_HOST" "cd $JUMP_DIR && bash $REMOTE_SCRIPT"
printf '\n测试服打包并推送完成。\n'
printf '若远程解压报 File exists:先在服务器清理旧 node_modules 后再跑 %s。\n' "$REMOTE_SCRIPT"
+222
View File
@@ -0,0 +1,222 @@
/**
* 将 dist/{production|test} 补齐目标 Linux 的 sharp 原生包,输出到
* dist/{env}-linux-{arch}-{libc}(对齐 hkdj-web,适配 pnpm)。
*/
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdtemp,
open,
readFile,
readdir,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const args = process.argv.slice(2).filter((arg) => arg !== "--");
const [arch = "x64", libc = "glibc", environment = "production", ...extra] = args;
if (
!["x64", "arm64"].includes(arch) ||
!["glibc", "musl"].includes(libc) ||
!["production", "test"].includes(environment) ||
extra.length
) {
throw new Error(
"Usage: tsx scripts/prepare-production-runtime.ts [x64|arm64] [glibc|musl] [production|test]",
);
}
// package-standalone 已将运行包目录统一为 .next(含 test)
const runtimeDirectory = ".next";
const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
const dist = join(root, "dist");
const source = join(dist, environment);
const target = join(dist, `${environment}-linux-${arch}-${libc}`);
const require = createRequire(join(root, "package.json"));
for (const directory of [dist, source]) {
const info = await lstat(directory);
if (info.isSymbolicLink() || !info.isDirectory() || (await realpath(directory)) !== directory) {
throw new Error(`Unsafe build directory: ${directory}`);
}
}
async function requireFile(base: string, name: string) {
const path = join(base, name);
const info = await lstat(path);
if (!info.isFile() || info.size === 0 || !(await realpath(path)).startsWith(base + "/")) {
throw new Error(`Missing, empty or unsafe runtime file: ${name}`);
}
}
const required = [
"start.mjs",
"server.js",
`${runtimeDirectory}/BUILD_ID`,
`config/${environment}.env`,
"package.json",
"ecosystem.config.cjs",
];
for (const file of required) await requireFile(source, file);
for (const directory of [`${runtimeDirectory}/static`, "public"]) {
if (!(await lstat(join(source, directory))).isDirectory()) {
throw new Error(`Missing ${directory}`);
}
}
const npmVersion = spawnSync("npm", ["--version"], { cwd: root, encoding: "utf8" });
if (npmVersion.error || npmVersion.status !== 0) throw new Error("Cannot read npm version");
const version = npmVersion.stdout.trim();
const match = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/.exec(version);
const [major = 0, minor = 0, patch = 0] = match ? match.slice(1).map(Number) : [];
if (major < 10 || (major === 10 && (minor < 9 || (minor === 9 && patch < 2)))) {
throw new Error(`npm >=10.9.2 is required for cross-platform installation; found ${version}`);
}
const sharpEntry = require.resolve("sharp");
let sharpPkgPath = dirname(sharpEntry);
while (true) {
try {
await readFile(join(sharpPkgPath, "package.json"));
break;
} catch {
const parent = dirname(sharpPkgPath);
if (parent === sharpPkgPath) throw new Error("无法定位 sharp 包目录");
sharpPkgPath = parent;
}
}
const sharpPkg = JSON.parse(await readFile(join(sharpPkgPath, "package.json"), "utf8")) as {
name?: string;
optionalDependencies?: Record<string, string>;
};
if (sharpPkg.name !== "sharp") throw new Error(`定位到的包不是 sharp:${sharpPkgPath}`);
const sharpPlatform = `${libc === "musl" ? "linuxmusl" : "linux"}-${arch}`;
const packages: [string, string, RegExp][] = [
[`@img/sharp-${sharpPlatform}`, "", /\.node$/],
[`@img/sharp-libvips-${sharpPlatform}`, "", /libvips.*\.so(?:\.\d+)*$/],
];
for (const entry of packages) {
const ver = sharpPkg.optionalDependencies?.[entry[0]];
if (!ver) throw new Error(`sharp 未声明可选依赖 ${entry[0]}`);
entry[1] = ver;
}
await rm(target, { recursive: true, force: true });
// 必须保留 standalone/pnpm 软链布局;dereference 会拆掉 @swc/helpers 等嵌套路径
await cp(source, target, {
recursive: true,
filter: (path) => {
const name = basename(path);
if (
name.startsWith(".env") ||
name.endsWith(".local.env") ||
name.startsWith("._") ||
name === ".DS_Store" ||
name.endsWith(".log")
) {
return false;
}
return true;
},
});
const nativeDir = await mkdtemp(join(dist, ".production-native-"));
try {
await writeFile(
join(nativeDir, "package.json"),
JSON.stringify({ name: "hjsq-native-runtime", version: "1.0.0", private: true }),
);
const specs = packages.map(([name, ver]) => `${name}@${ver}`);
console.log(`Installing Linux ${arch}/${libc} sharp packages with npm ${version}`);
const install = spawnSync(
"npm",
[
"install",
...specs,
"--omit=dev",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--force",
"--os=linux",
`--cpu=${arch}`,
`--libc=${libc}`,
],
{
cwd: nativeDir,
stdio: "inherit",
env: {
...process.env,
npm_config_platform: "linux",
npm_config_arch: arch,
npm_config_libc: libc,
},
},
);
if (install.error || install.status !== 0) {
throw new Error(`Native npm install failed (${install.status ?? install.error?.message})`);
}
for (const [name, ver, pattern] of packages) {
const directory = join(nativeDir, "node_modules", name);
const metadata = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
if (metadata.name !== name || metadata.version !== ver) {
throw new Error(`Incorrect native package: ${name}`);
}
const binaries = await matchingFiles(directory, pattern);
if (!binaries.length) throw new Error(`Native runtime binary is missing: ${name}`);
for (const binary of binaries) await verifyELF(binary);
await mkdirp(join(target, "node_modules", "@img"));
await cp(directory, join(target, "node_modules", name), { recursive: true });
}
} finally {
await rm(nativeDir, { recursive: true, force: true });
}
for (const file of [...required, "node_modules/dotenv/package.json"]) {
await requireFile(target, file);
}
await writeFile(
join(target, "runtime-target.json"),
JSON.stringify({ platform: "linux", arch, libc, environment, packaging: "standalone" }, null, 2) +
"\n",
);
console.log(`Linux runtime ready: ${target}`);
async function mkdirp(path: string) {
const { mkdir } = await import("node:fs/promises");
await mkdir(path, { recursive: true });
}
async function matchingFiles(directory: string, pattern: RegExp): Promise<string[]> {
const found: string[] = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) found.push(...(await matchingFiles(path, pattern)));
else if (entry.isFile() && pattern.test(entry.name)) found.push(path);
}
return found;
}
async function verifyELF(path: string) {
const file = await open(path, "r");
const header = Buffer.alloc(64);
try {
await file.read(header, 0, header.length, 0);
} finally {
await file.close();
}
if (
!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
header[4] !== 2 ||
header[5] !== 1 ||
header.readUInt16LE(18) !== (arch === "x64" ? 62 : 183)
) {
throw new Error(`Native runtime is not a Linux ${arch} ELF binary: ${path}`);
}
}