225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
/**
|
||
* 将 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 等嵌套路径
|
||
// verbatimSymlinks:软链保持相对路径,否则会被改写成本机绝对路径
|
||
await cp(source, target, {
|
||
recursive: true,
|
||
verbatimSymlinks: 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}`);
|
||
}
|
||
}
|