@@ -0,0 +1,28 @@
|
||||
const [origin,environment]=process.argv.slice(2);
|
||||
if(!origin||!['test','production'].includes(environment))throw new Error('Usage: node scripts/check-health.mjs <origin> test|production');
|
||||
async function read(path) {
|
||||
const response=await fetch(new URL(path,origin),{signal:AbortSignal.timeout(5000),redirect:'error',cache:'no-store'});
|
||||
if(response.status!==200)throw new Error(`${path} HTTP ${response.status}`);
|
||||
let text='',size=0;
|
||||
const decoder=new TextDecoder();
|
||||
for await(const part of response.body) {
|
||||
size+=part.length;if(size>1048576)throw new Error(`${path} 响应超过检查上限`);
|
||||
text+=decoder.decode(part,{stream:true});
|
||||
if(path==='/'&&text.includes('/_next/'))break;
|
||||
}
|
||||
return {text,type:response.headers.get('content-type')||''};
|
||||
}
|
||||
let healthy=false;
|
||||
for(let attempt=1;attempt<=5;attempt++) {
|
||||
try {
|
||||
const health=await read('/healthz'),status=JSON.parse(health.text);
|
||||
if(status.status!=='ok'||status.environment!==environment||status.application!=='jha-1050-jgdj-web')throw new Error('/healthz 未命中本站对应环境');
|
||||
const home=await read('/');
|
||||
if(!/^text\/html(?:;|$)/i.test(home.type)||!home.text.includes('/_next/'))throw new Error('首页不是 Next HTML');
|
||||
healthy=true;console.log('/healthz 与 Next 首页检查通过');break;
|
||||
}catch(error) {
|
||||
console.error(`健康检查 ${attempt}/5 失败:${error.message}`);
|
||||
if(attempt<5)await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
}
|
||||
}
|
||||
process.exitCode=healthy?0:1;
|
||||
@@ -0,0 +1,76 @@
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import {dirname, join, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'..');
|
||||
/** Runtime packages that must match package-lock.json when building or deploying. */
|
||||
export const LOCKED_RUNTIME=['next','react','react-dom','dotenv','hls.js','qrcode'];
|
||||
|
||||
export function packageLockVersions(rootDir=root) {
|
||||
const lock=JSON.parse(readFileSync(join(rootDir,'package-lock.json'),'utf8'));
|
||||
const versions={};
|
||||
for(const name of LOCKED_RUNTIME){
|
||||
const entry=lock.packages?.[`node_modules/${name}`];
|
||||
if(!entry?.version)throw new Error(`package-lock.json 缺少 ${name}`);
|
||||
versions[name]=entry.version;
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
export function installedVersions(rootDir=root,names=LOCKED_RUNTIME) {
|
||||
const require=createRequire(join(rootDir,'package.json'));
|
||||
const versions={};
|
||||
for(const name of names){
|
||||
try{
|
||||
versions[name]=require(`${name}/package.json`).version;
|
||||
}catch{
|
||||
throw new Error(`未安装 ${name},请先执行 npm ci 或 pnpm install`);
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
/** Best-effort read of direct dependency versions from pnpm-lock.yaml importers. */
|
||||
export function pnpmLockVersions(rootDir=root) {
|
||||
const file=join(rootDir,'pnpm-lock.yaml');
|
||||
if(!existsSync(file))return null;
|
||||
const text=readFileSync(file,'utf8');
|
||||
const versions={};
|
||||
for(const name of LOCKED_RUNTIME){
|
||||
const escaped=name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
||||
const match=text.match(new RegExp(`(?:^|\\n)\\s{6}${escaped}:\\n(?:\\s{8}.*\\n)*?\\s{8}version:\\s+(\\S+)`));
|
||||
if(!match)throw new Error(`pnpm-lock.yaml 缺少 ${name}`);
|
||||
versions[name]=match[1].replace(/\(.*$/,'');
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
export function checkLockSync(rootDir=root) {
|
||||
const expected=packageLockVersions(rootDir);
|
||||
const installed=installedVersions(rootDir);
|
||||
const mismatches=[];
|
||||
for(const name of LOCKED_RUNTIME){
|
||||
if(installed[name]!==expected[name])mismatches.push(`${name}: 已安装 ${installed[name]},package-lock.json 为 ${expected[name]}`);
|
||||
}
|
||||
const pnpm=pnpmLockVersions(rootDir);
|
||||
if(pnpm){
|
||||
for(const name of LOCKED_RUNTIME){
|
||||
if(pnpm[name]!==expected[name])mismatches.push(`${name}: pnpm-lock.yaml 为 ${pnpm[name]},package-lock.json 为 ${expected[name]}`);
|
||||
}
|
||||
}
|
||||
if(mismatches.length){
|
||||
throw new Error(`依赖版本不一致(发版以 package-lock.json / npm ci 为准):\n- ${mismatches.join('\n- ')}\n请用同一版本同步:npm install 与 pnpm install,或将 next 等依赖钉死后再更新两份 lock。`);
|
||||
}
|
||||
return {expected,installed,pnpm};
|
||||
}
|
||||
|
||||
if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const result=checkLockSync();
|
||||
console.log(`lock sync ok (npm${result.pnpm?'+pnpm':''}): next@${result.expected.next}`);
|
||||
}catch(error) {
|
||||
console.error(error instanceof Error?error.message:error);
|
||||
process.exitCode=1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {cp, copyFile, lstat, mkdir, readdir, realpath, symlink} from 'node:fs/promises';
|
||||
import {dirname, join, relative, sep} from 'node:path';
|
||||
|
||||
export const under=(path,base)=>path===base||path.startsWith(base+sep);
|
||||
|
||||
/**
|
||||
* Copy a release tree for npm or pnpm layouts.
|
||||
* In-tree symlinks are rewritten as relative links inside `to` so the package is relocatable.
|
||||
* Symlinks that escape `from` but stay under `root` are materialized; anything else is rejected.
|
||||
*/
|
||||
export async function copyReleaseTree(from,to,{root=from,filter=async()=>true}={}) {
|
||||
from=await realpath(from);
|
||||
root=await realpath(root);
|
||||
async function walk(srcDir,destDir) {
|
||||
await mkdir(destDir,{recursive:true});
|
||||
for(const entry of await readdir(srcDir,{withFileTypes:true})) {
|
||||
const srcPath=join(srcDir,entry.name);
|
||||
if(!await filter(srcPath))continue;
|
||||
const destPath=join(destDir,entry.name);
|
||||
if(entry.isSymbolicLink()){
|
||||
const target=await realpath(srcPath).catch(()=>null);
|
||||
if(!target||(!under(target,from)&&!under(target,root)))
|
||||
throw new Error(`Release source contains a symbolic link: ${srcPath}`);
|
||||
if(under(target,from)){
|
||||
const mapped=join(to,relative(from,target));
|
||||
await symlink(relative(dirname(destPath),mapped),destPath);
|
||||
continue;
|
||||
}
|
||||
await cp(target,destPath,{recursive:true,dereference:true,filter});
|
||||
continue;
|
||||
}
|
||||
if(entry.isDirectory()){
|
||||
await walk(srcPath,destPath);
|
||||
continue;
|
||||
}
|
||||
if(entry.isFile()){
|
||||
await copyFile(srcPath,destPath);
|
||||
continue;
|
||||
}
|
||||
// Rare non-file entries (sockets/devices) should never ship.
|
||||
throw new Error(`Release source contains an unsupported file type: ${srcPath}`);
|
||||
}
|
||||
}
|
||||
await walk(from,to);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Shared by deploy_test.sh / deploy_prod.sh; compatible with macOS Bash 3.2.
|
||||
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
|
||||
step() { printf '\n▸ %s\n' "$*"; }
|
||||
need() { command -v "$1" >/dev/null || fail "缺少命令 $1"; }
|
||||
valid_host() { [[ "$1" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail "$2 格式无效或未配置"; }
|
||||
valid_path() { [[ "$1" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*/?$ ]] && [[ "$1/" != *'/../'* ]] && [[ "$1/" != *'/./'* ]] || fail "$2 必须是普通绝对路径"; }
|
||||
valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ] || fail "$2 必须在 1–65535 内"; }
|
||||
check_local() {
|
||||
need node; need npm
|
||||
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,推荐 22.17.1'
|
||||
# Local install may come from npm or pnpm; server updates always use package-lock.json via npm ci.
|
||||
[ -d node_modules/next ] && [ -d node_modules/dotenv ] || fail '请先执行 npm ci 或 pnpm install 安装本地依赖'
|
||||
[ -s "config/$1.env" ] && [ -s package-lock.json ] || fail "缺少 config/$1.env 或 package-lock.json"
|
||||
node scripts/check-lock-sync.mjs || fail '本地依赖与 package-lock.json / pnpm-lock.yaml 不一致'
|
||||
}
|
||||
public_profile() {
|
||||
node --input-type=module -e 'import{readFileSync}from"node:fs";import{parse}from"dotenv";const c=parse(readFileSync(`config/${process.argv[1]}.env`));console.log(` API=${c.UPSTREAM_API_URL||c.API_BASE_URL} PORT=${c.PORT}`)' "$1"
|
||||
}
|
||||
lock_build() {
|
||||
[ ! -L dist ] || fail 'dist 不允许为符号链接'
|
||||
mkdir -p dist
|
||||
DEPLOY_LOCK="$SCRIPT_DIR/dist/.deploy.lock"
|
||||
mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有构建/发布进程或残留锁:$DEPLOY_LOCK"
|
||||
trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { parse } from "dotenv";
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const directories = {
|
||||
development: ".next-dev",
|
||||
test: ".next-test",
|
||||
production: ".next",
|
||||
};
|
||||
const commands = new Set(["dev", "build", "start"]);
|
||||
const readConfig = (file) =>
|
||||
existsSync(file) ? parse(readFileSync(file)) : {};
|
||||
// Normalize each layer before merging so a legacy process override still wins over a profile.
|
||||
const apiConfig = (values) => ({
|
||||
...values,
|
||||
...(!Object.hasOwn(values, "UPSTREAM_API_URL") &&
|
||||
Object.hasOwn(values, "API_BASE_URL")
|
||||
? { UPSTREAM_API_URL: values.API_BASE_URL }
|
||||
: {}),
|
||||
});
|
||||
|
||||
/** APP_ENV selects the deployment; NODE_ENV keeps Next's development/production semantics. */
|
||||
export function nextCommand(
|
||||
environment = "development",
|
||||
command = "dev",
|
||||
args = [],
|
||||
{ root = projectRoot, variables = process.env } = {},
|
||||
) {
|
||||
if (!Object.hasOwn(directories, environment) || !commands.has(command))
|
||||
throw new Error(
|
||||
"Usage: node scripts/next-env.mjs development|test|production dev|build|start",
|
||||
);
|
||||
const base = resolve(root, "config", environment + ".env");
|
||||
if (!existsSync(base))
|
||||
throw new Error(`缺少环境配置:config/${environment}.env`);
|
||||
const env = {
|
||||
...apiConfig(readConfig(base)),
|
||||
// Preserve the old local-development setup without leaking it into test/production.
|
||||
...(environment === "development"
|
||||
? apiConfig(readConfig(resolve(root, ".env.local")))
|
||||
: {}),
|
||||
...apiConfig(
|
||||
readConfig(resolve(root, "config", environment + ".local.env")),
|
||||
),
|
||||
...apiConfig(
|
||||
Object.fromEntries(
|
||||
Object.entries(variables).filter(([, value]) => value !== undefined),
|
||||
),
|
||||
),
|
||||
APP_ENV: environment,
|
||||
APP_DIST_DIR: directories[environment],
|
||||
NODE_ENV: command === "dev" ? "development" : "production",
|
||||
};
|
||||
try {
|
||||
const value = (env.UPSTREAM_API_URL || "").trim();
|
||||
if (value === "current") env.UPSTREAM_API_URL = value;
|
||||
else {
|
||||
const api = new URL(value);
|
||||
if (
|
||||
!["http:", "https:"].includes(api.protocol) ||
|
||||
api.username ||
|
||||
api.password ||
|
||||
api.search ||
|
||||
api.hash
|
||||
)
|
||||
throw new Error();
|
||||
env.UPSTREAM_API_URL = api.href.replace(/\/+$/, "");
|
||||
}
|
||||
env.API_BASE_URL = env.UPSTREAM_API_URL;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`config/${environment}.env 的 UPSTREAM_API_URL(兼容 API_BASE_URL)必须为 current 或有效的 HTTP(S) 接口地址`,
|
||||
);
|
||||
}
|
||||
const port = String(env.PORT || "21030");
|
||||
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535)
|
||||
throw new Error("PORT 必须在 1–65535 之间");
|
||||
env.PORT = port;
|
||||
const hasOption = (names) =>
|
||||
args.some((arg) =>
|
||||
names.some((name) => arg === name || arg.startsWith(name + "=")),
|
||||
);
|
||||
const options =
|
||||
command === "build"
|
||||
? []
|
||||
: [
|
||||
...(hasOption(["--hostname", "-H"]) ? [] : ["--hostname", "0.0.0.0"]),
|
||||
...(hasOption(["--port", "-p"]) ? [] : ["--port", port]),
|
||||
];
|
||||
return {
|
||||
executable: process.execPath,
|
||||
args: [
|
||||
resolve(root, "node_modules/next/dist/bin/next"),
|
||||
command,
|
||||
...options,
|
||||
...args,
|
||||
],
|
||||
cwd: root,
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runNext(environment, command, args, options) {
|
||||
const spec = nextCommand(environment, command, args, options);
|
||||
const child = spawn(spec.executable, spec.args, {
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
const interrupt = () => child.kill("SIGINT"),
|
||||
terminate = () => child.kill("SIGTERM");
|
||||
process.on("SIGINT", interrupt);
|
||||
process.on("SIGTERM", terminate);
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) =>
|
||||
resolve(code ?? (signal === "SIGINT" ? 130 : 143)),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
process.off("SIGINT", interrupt);
|
||||
process.off("SIGTERM", terminate);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
try {
|
||||
const [environment = "development", command = "dev", ...args] =
|
||||
process.argv.slice(2);
|
||||
process.exitCode = await runNext(environment, command, args);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : "启动 Next.js 失败");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {cp, lstat, mkdir, readFile, realpath, rm, writeFile} from 'node:fs/promises';
|
||||
import {basename, join, relative, resolve, sep} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {copyReleaseTree, under} from './copy-release-tree.mjs';
|
||||
|
||||
const projectRoot=fileURLToPath(new URL('../',import.meta.url));
|
||||
|
||||
/** Assemble a relocatable release; only the selected public environment profile is shipped. */
|
||||
export async function packageStandalone(environment,{root=projectRoot}={}) {
|
||||
if(!['test','production'].includes(environment))throw new Error('Usage: node scripts/package-standalone.mjs test|production');
|
||||
root=await realpath(root);
|
||||
const build=environment==='test'?'.next-test':'.next';
|
||||
const dist=join(root,'dist'),destination=join(dist,environment),source=join(root,build,'standalone');
|
||||
async function directory(path,optional=false) {
|
||||
const info=await lstat(path).catch(error=>{if(optional&&error.code==='ENOENT')return null;throw error;});
|
||||
if(info&&(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path))throw new Error(`Unsafe release directory: ${path}`);
|
||||
}
|
||||
for(const path of [join(root,build),source])await directory(path);
|
||||
for(const file of [join(source,'server.js'),join(source,build,'BUILD_ID'),join(root,'config',environment+'.env')]) {
|
||||
if(!(await lstat(file)).isFile()||!(await readFile(file)).length)throw new Error(`Missing release file: ${file}`);
|
||||
}
|
||||
await directory(dist,true);await mkdir(dist,{recursive:true});await directory(destination,true);
|
||||
// A fixed dist/<environment> is the only location removed by this command.
|
||||
await rm(destination,{recursive:true,force:true});
|
||||
const filter=async path=>{
|
||||
const name=basename(path);
|
||||
if(name.startsWith('.env')||name.endsWith('.local.env')||name.startsWith('._')||name==='.DS_Store'||name.endsWith('.log'))return false;
|
||||
// Dynamic filesystem reads can make Next trace sibling build/cache/test directories.
|
||||
// Keep this project's runtime roots, never recursively include earlier dist releases.
|
||||
if(path.startsWith(source+sep)&&!['server.js','package.json','package-lock.json',build,'node_modules','src'].includes(relative(source,path).split(sep)[0]))return false;
|
||||
if(path===join(source,build,'cache'))return false;
|
||||
// npm installs a flat tree; pnpm/Next standalone keeps relative in-tree links.
|
||||
// copyReleaseTree rewrites safe links; reject only targets that escape the project root.
|
||||
if((await lstat(path)).isSymbolicLink()){
|
||||
const target=await realpath(path).catch(()=>null);
|
||||
if(!target||!under(target,root))throw new Error(`Release source contains a symbolic link: ${path}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
await copyReleaseTree(source,destination,{root,filter});
|
||||
// public/static are plain trees; dotenv is pnpm-linked — ship a concrete copy for runtime helpers.
|
||||
for(const [from,to] of [[join(root,'public'),'public'],[join(root,build,'static'),build+'/static'],
|
||||
[join(root,'node_modules/dotenv'),'node_modules/dotenv']]) {
|
||||
await cp(await realpath(from),join(destination,to),{recursive:true,dereference:true,filter});
|
||||
}
|
||||
await mkdir(join(destination,'config'),{recursive:true});await mkdir(join(destination,'scripts'),{recursive:true});
|
||||
for(const file of ['package.json','package-lock.json','scripts/next-env.mjs','scripts/check-health.mjs','config/'+environment+'.env']) {
|
||||
await cp(join(root,file),join(destination,file));
|
||||
}
|
||||
await writeFile(join(destination,'start.mjs'),`import {fileURLToPath} from 'node:url';
|
||||
import {nextCommand} from './scripts/next-env.mjs';
|
||||
const root=fileURLToPath(new URL('./',import.meta.url));
|
||||
process.chdir(root);
|
||||
const {env}=nextCommand(${JSON.stringify(environment)},'start',[],{root});
|
||||
Object.assign(process.env,env);
|
||||
process.env.HOSTNAME=process.env.HOSTNAME||'0.0.0.0';
|
||||
await import('./server.js');
|
||||
`);
|
||||
console.log(`Standalone release ready: ${destination}`);
|
||||
return destination;
|
||||
}
|
||||
|
||||
if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
if(process.argv.length!==3)throw new Error('Usage: node scripts/package-standalone.mjs test|production');
|
||||
await packageStandalone(process.argv[2]);
|
||||
}catch(error){console.error(error.message);process.exitCode=1;}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { cp, lstat, mkdtemp, open, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { checkLockSync } from './check-lock-sync.mjs';
|
||||
import { copyReleaseTree, under } from './copy-release-tree.mjs';
|
||||
const [arch = 'x64', libc = 'glibc', ...extra] = process.argv.slice(2);
|
||||
if (!['x64', 'arm64'].includes(arch) || !['glibc', 'musl'].includes(libc) || extra.length) {
|
||||
throw new Error('Usage: node scripts/prepare-production-runtime.mjs [x64|arm64] [glibc|musl]');
|
||||
}
|
||||
const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
|
||||
const dist = join(root, 'dist');
|
||||
const source = join(dist, 'production');
|
||||
const target = join(dist, `production-linux-${arch}-${libc}`);
|
||||
if (dirname(target) !== dist || !/^production-linux-(x64|arm64)-(glibc|musl)$/.test(basename(target))) {
|
||||
throw new Error('Runtime destination must be a fixed repository dist directory');
|
||||
}
|
||||
checkLockSync(root);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
const oldTarget = await lstat(target).catch((error) => {
|
||||
if (error.code !== 'ENOENT')
|
||||
throw error;
|
||||
return null;
|
||||
});
|
||||
if (oldTarget?.isSymbolicLink())
|
||||
throw new Error('Runtime destination cannot be a symbolic link');
|
||||
async function requireFile(base, name) {
|
||||
const path = join(base, name);
|
||||
const real = await realpath(path).catch(() => null);
|
||||
if (!real || !under(real, base))
|
||||
throw new Error(`Missing, empty or unsafe runtime file: ${name}`);
|
||||
const info = await stat(path);
|
||||
if (!info.isFile() || info.size === 0)
|
||||
throw new Error(`Missing, empty or unsafe runtime file: ${name}`);
|
||||
}
|
||||
const required = ['start.mjs', 'server.js', '.next/BUILD_ID', 'config/production.env'];
|
||||
for (const file of required)
|
||||
await requireFile(source, file);
|
||||
for (const directory of ['.next/static', 'public']) {
|
||||
if (!(await lstat(join(source, directory))).isDirectory())
|
||||
throw new Error(`Missing ${directory}`);
|
||||
}
|
||||
for (const file of ['package.json', 'package-lock.json'])
|
||||
await requireFile(root, file);
|
||||
const originalLock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8'));
|
||||
for (const file of await matchingFiles(join(source, 'node_modules'), /^package\.json$/)) {
|
||||
const locked = originalLock.packages[relative(source, dirname(file))];
|
||||
if (locked && JSON.parse(await readFile(file, 'utf8')).version !== locked.version) {
|
||||
throw new Error(`Traced dependency does not match the root lockfile: ${file}`);
|
||||
}
|
||||
}
|
||||
// npm flat trees and pnpm link farms both resolve through Node; keep next aligned with package-lock.
|
||||
const standaloneNext = createRequire(join(source, 'package.json'))('next/package.json').version;
|
||||
if (standaloneNext !== originalLock.packages['node_modules/next']?.version) {
|
||||
throw new Error(`Standalone next@${standaloneNext} does not match package-lock.json`);
|
||||
}
|
||||
const npmVersion = spawnSync('npm', ['--version'], { cwd: root, encoding: 'utf8' });
|
||||
if (npmVersion.error || npmVersion.status !== 0)
|
||||
throw new Error('Cannot read the current 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}`);
|
||||
}
|
||||
// Only this validated, fixed dist destination can be removed; source and host dependencies stay intact.
|
||||
const sharpPlatform = `${libc === 'musl' ? 'linuxmusl' : 'linux'}-${arch}`;
|
||||
const packages = [
|
||||
[`@img/sharp-${sharpPlatform}`, /\.node$/],
|
||||
[`@img/sharp-libvips-${sharpPlatform}`, /libvips.*\.so(?:\.\d+)*$/],
|
||||
];
|
||||
await rm(target, { recursive: true, force: true });
|
||||
const filter = async (path) => {
|
||||
const name = basename(path);
|
||||
if (name.startsWith('.env') || name.endsWith('.local.env') ||
|
||||
name.startsWith('._') || name === '.DS_Store' || name.endsWith('.log'))
|
||||
return false;
|
||||
const scope = basename(dirname(path));
|
||||
if (scope === '@next' && name.startsWith('swc-'))
|
||||
return false;
|
||||
if (scope === '@img' && name.startsWith('sharp-') && name !== 'sharp-wasm32' &&
|
||||
!packages.some(([packageName]) => packageName === `@img/${name}`))
|
||||
return false;
|
||||
// copyReleaseTree rewrites safe in-tree links; reject targets outside the production package / repo.
|
||||
if ((await lstat(path)).isSymbolicLink()) {
|
||||
const linkTarget = await realpath(path).catch(() => null);
|
||||
if (!linkTarget || (!under(linkTarget, source) && !under(linkTarget, root)))
|
||||
throw new Error(`Runtime source contains a symbolic link: ${path}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// Prefer relocatable in-tree links; materialize any leftover links that still point at the build tree.
|
||||
await copyReleaseTree(source, target, { root, filter });
|
||||
for (const file of ['package.json', 'package-lock.json'])
|
||||
await cp(join(root, file), join(target, file));
|
||||
// Preserve Next's traced JS tree. Install only the two platform packages, never the full app dependency graph.
|
||||
const nativeDir = await mkdtemp(join(dist, '.production-native-'));
|
||||
try {
|
||||
const optionalDependencies = {}, records = {};
|
||||
for (const [name] of packages) {
|
||||
const entry = originalLock.packages[`node_modules/${name}`];
|
||||
if (!entry?.version || !entry.resolved || !entry.integrity)
|
||||
throw new Error(`Missing locked native package: ${name}`);
|
||||
optionalDependencies[name] = entry.version;
|
||||
const record = { ...entry, optional: true };
|
||||
delete record.dev;
|
||||
delete record.devOptional;
|
||||
records[`node_modules/${name}`] = record;
|
||||
}
|
||||
const manifest = { name: 'jgdj-native-runtime', version: '1.0.0', private: true, optionalDependencies };
|
||||
await writeFile(join(nativeDir, 'package.json'), JSON.stringify(manifest));
|
||||
await writeFile(join(nativeDir, 'package-lock.json'), JSON.stringify({ name: manifest.name,
|
||||
version: manifest.version, lockfileVersion: 3, requires: true, packages: { '': manifest, ...records } }));
|
||||
console.log(`Installing two locked Linux ${arch}/${libc} native packages with npm ${version}`);
|
||||
const install = spawnSync('npm', ['ci', '--omit=dev', '--include=optional', '--ignore-scripts',
|
||||
'--no-audit', '--no-fund', '--os=linux', `--cpu=${arch}`, `--libc=${libc}`], { cwd: nativeDir, stdio: 'inherit' });
|
||||
if (install.error || install.status !== 0)
|
||||
throw new Error(`Native npm ci failed (${install.status ?? install.error?.message})`);
|
||||
for (const [name, 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 !== optionalDependencies[name] ||
|
||||
!metadata.os?.includes('linux') || !metadata.cpu?.includes(arch))
|
||||
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 cp(directory, join(target, 'node_modules', name), { recursive: true });
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await rm(nativeDir, { recursive: true, force: true });
|
||||
}
|
||||
for (const file of required)
|
||||
await requireFile(target, file);
|
||||
// npm hoists these to node_modules/*; pnpm keeps them under .pnpm — resolve from next / package root.
|
||||
const rootRequire = createRequire(join(target, 'package.json'));
|
||||
const nextRequire = createRequire(join(await realpath(join(target, 'node_modules/next')), 'package.json'));
|
||||
if (typeof rootRequire('dotenv').parse !== 'function')
|
||||
throw new Error('Runtime dotenv is unavailable');
|
||||
for (const name of ['sharp', 'detect-libc', 'semver', '@img/colour']) {
|
||||
try {
|
||||
nextRequire.resolve(name);
|
||||
}
|
||||
catch {
|
||||
throw new Error(`Runtime ${name} is unavailable`);
|
||||
}
|
||||
}
|
||||
for (const [name] of packages)
|
||||
await requireFile(target, `node_modules/${name}/package.json`);
|
||||
async function matchingFiles(directory, pattern, rootDir = directory) {
|
||||
const rootReal = await realpath(rootDir);
|
||||
const found = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const linkTarget = await realpath(path).catch(() => null);
|
||||
if (!linkTarget || !under(linkTarget, rootReal))
|
||||
continue;
|
||||
const info = await stat(path);
|
||||
if (info.isDirectory())
|
||||
found.push(...await matchingFiles(path, pattern, rootReal));
|
||||
else if (info.isFile() && pattern.test(entry.name))
|
||||
found.push(path);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory())
|
||||
found.push(...await matchingFiles(path, pattern, rootReal));
|
||||
else if (entry.isFile() && pattern.test(entry.name))
|
||||
found.push(path);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
async function verifyELF(path) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
// These public target details describe the package; environment values and credentials are never copied here.
|
||||
await writeFile(join(target, 'runtime-target.json'), JSON.stringify({ platform: 'linux', arch, libc, packaging: 'standalone' }, null, 2) + '\n');
|
||||
console.log(`Linux runtime ready: ${target}`);
|
||||
@@ -0,0 +1,223 @@
|
||||
// Read-only business smoke checks; only the normal guest-session handshake is a POST.
|
||||
// Cookies stay in memory and are never printed or written to disk.
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const base = process.env.PREVIEW_URL || "http://127.0.0.1:21030";
|
||||
const publicOnly = process.env.SMOKE_PUBLIC_ONLY === "1";
|
||||
const cookies = new Map();
|
||||
const cookieHeader = () =>
|
||||
[...cookies].map(([key, value]) => `${key}=${value}`).join("; ");
|
||||
async function request(path, body) {
|
||||
const response = await fetch(`${base}/api/app/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
Cookie: cookieHeader(),
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
for (const item of response.headers.getSetCookie()) {
|
||||
const [pair] = item.split(";");
|
||||
const index = pair.indexOf("=");
|
||||
cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
||||
}
|
||||
const envelope = await response.json();
|
||||
assert.equal(response.status, 200, `${path}: HTTP ${response.status}`);
|
||||
assert.equal(envelope.code, 200, `${path}: ${envelope.msg || envelope.tip}`);
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
const home = await request("playlet/home");
|
||||
const response = await fetch(base);
|
||||
const html = await response.text();
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(!html.includes("data-must-watch-sort"), "首页筛选项应保持移除");
|
||||
if (!publicOnly) {
|
||||
const guest = await request("auth/guest", {});
|
||||
assert.ok(guest.userId, "游客登录应返回用户 ID");
|
||||
assert.equal(guest.token, undefined, "token 不应暴露给浏览器脚本");
|
||||
assert.ok(cookies.has("drama_session"), "应通过 Cookie 保持会话");
|
||||
console.log("PASS: HttpOnly guest session");
|
||||
}
|
||||
console.log("PASS: SSR homepage");
|
||||
|
||||
const checks = [
|
||||
"config/info",
|
||||
"config/footer/info",
|
||||
"navigation/header",
|
||||
"translation/category/list",
|
||||
"theme/list",
|
||||
"playlet/tag/options",
|
||||
"playlet/all/list?sortType=1&pageNum=1&pageSize=4",
|
||||
"playlet/operate/list?type=2&pageNum=1&pageSize=4",
|
||||
...[1, 2, 3].map(
|
||||
(rank) => `playlet/hot/rank/list?rankType=${rank}&pageNum=1&pageSize=4`,
|
||||
),
|
||||
"playlet/topic/list?pageNum=1&pageSize=4",
|
||||
"applist/list?categoryId=0",
|
||||
"recharge/package/list",
|
||||
"playlet/hot-like",
|
||||
...(!publicOnly
|
||||
? [
|
||||
"auth/getInfo",
|
||||
"user/center/data",
|
||||
"playlet/history/list?pageNum=1&pageSize=4",
|
||||
"playlet/collect/list?pageNum=1&pageSize=4&watchStatus=0",
|
||||
"playlet/bought/list?pageNum=1&pageSize=4",
|
||||
"coin/trans/list?page=1&pageSize=10&type=0",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const dataByPath = new Map();
|
||||
for (let offset = 0; offset < checks.length; offset += 4)
|
||||
await Promise.all(
|
||||
checks.slice(offset, offset + 4).map(async (path) => {
|
||||
const data = await request(path);
|
||||
assert.ok(data, `${path}: missing data`);
|
||||
dataByPath.set(path.split("?")[0], data);
|
||||
console.log(`PASS: ${path.split("?")[0]}`);
|
||||
}),
|
||||
);
|
||||
const pages = [
|
||||
"/shorts/",
|
||||
"/shorts/page/2/",
|
||||
"/rank/hot/",
|
||||
"/rank/new/",
|
||||
"/rank/follow/",
|
||||
"/topics/",
|
||||
"/apps/",
|
||||
"/home-road/",
|
||||
"/navigation/",
|
||||
"/info/about-us/",
|
||||
"/info/user-agreement/",
|
||||
"/info/privacy-policy/",
|
||||
"/info/help-center/",
|
||||
"/info/2257/",
|
||||
"/info/dmca/",
|
||||
"/info/feedback/",
|
||||
];
|
||||
const topic = dataByPath
|
||||
.get("playlet/topic/list")
|
||||
?.list?.flatMap((group) => group.list || [])?.[0];
|
||||
if (topic) {
|
||||
const detail = await request(
|
||||
`playlet/topic/detail?id=${topic.id}&pageNum=1&pageSize=4`,
|
||||
);
|
||||
assert.equal(detail.id, topic.id);
|
||||
assert.equal(typeof detail.name, "string");
|
||||
assert.ok(Array.isArray(detail.list));
|
||||
pages.push(`/topics/${topic.id}/`);
|
||||
}
|
||||
const tag = dataByPath.get("playlet/tag/options")?.list?.[0];
|
||||
if (tag) pages.push(`/tag/${tag.id}/`);
|
||||
const theme = dataByPath.get("theme/list")?.list?.[0];
|
||||
if (theme) pages.push(`/theme/${theme.id}/`);
|
||||
const block = home.modules?.find((item) => [1, 2].includes(item.moduleType));
|
||||
if (block) {
|
||||
const page = await request(
|
||||
`playlet/home-config/list?id=${block.id}&pageNum=1&pageSize=4`,
|
||||
);
|
||||
assert.equal(page.config.id, block.id);
|
||||
pages.push(`/config/${block.id}/`);
|
||||
}
|
||||
const first =
|
||||
home.homeBannerData?.[0] ||
|
||||
home.modules?.find((block) => block.list?.length)?.list[0];
|
||||
if (first) {
|
||||
const search = await request(
|
||||
"playlet/search?" +
|
||||
new URLSearchParams({
|
||||
keyword: first.playName || first.title,
|
||||
pageNum: "1",
|
||||
pageSize: "4",
|
||||
}),
|
||||
);
|
||||
assert.ok(Array.isArray(search.list));
|
||||
pages.push(
|
||||
"/search/" + encodeURIComponent(first.playName || first.title) + "/",
|
||||
);
|
||||
const suggestions = await request(
|
||||
"playlet/search/suggest?" +
|
||||
new URLSearchParams({ keyword: first.playName || first.title }),
|
||||
);
|
||||
assert.ok(Array.isArray(suggestions.list));
|
||||
if (!publicOnly) {
|
||||
const detail = await request(`playlet/detail?id=${first.id}`);
|
||||
assert.equal(detail.id, first.id);
|
||||
const chapters = await request(
|
||||
`playlet/chapter/list?playletId=${first.id}`,
|
||||
);
|
||||
const related = await request(
|
||||
`playlet/guess-like/list?playletId=${first.id}&pageNum=1&pageSize=6`,
|
||||
);
|
||||
assert.ok(Array.isArray(related.list));
|
||||
pages.push(`/detail/${first.id}/`);
|
||||
if (chapters.list?.length) {
|
||||
const chapter = await request(
|
||||
`playlet/chapter/detail?playletId=${first.id}&chapterId=${chapters.list[0].id}`,
|
||||
);
|
||||
assert.equal(chapter.id, chapters.list[0].id);
|
||||
assert.equal(typeof chapter.canWatch, "boolean");
|
||||
pages.push(`/play/${first.id}/${chapter.id}/`);
|
||||
}
|
||||
console.log("PASS: drama detail and chapter permissions");
|
||||
}
|
||||
console.log("PASS: search");
|
||||
}
|
||||
if (!publicOnly)
|
||||
pages.push(
|
||||
"/member/center/",
|
||||
"/member/favorites/",
|
||||
"/member/history/",
|
||||
"/member/purchased/",
|
||||
"/member/settings/",
|
||||
"/info/deposit/",
|
||||
"/info/deposit/vip/",
|
||||
);
|
||||
for (let offset = 0; offset < pages.length; offset += 3)
|
||||
await Promise.all(
|
||||
pages.slice(offset, offset + 3).map(async (path) => {
|
||||
const response = await fetch(base + path, {
|
||||
headers: { Cookie: cookieHeader() },
|
||||
redirect: "manual",
|
||||
}),
|
||||
html = await response.text();
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal(response.headers.get("location"), null, path);
|
||||
assert.ok(!html.includes("内容暂不可用,请稍后重试"), path);
|
||||
assert.ok(!html.includes('class="api-loading"'), path);
|
||||
const marker = path.startsWith("/member/settings/")
|
||||
? "member-settings"
|
||||
: path.startsWith("/member/")
|
||||
? "member-center"
|
||||
: path.startsWith("/info/deposit/")
|
||||
? "wallet-page"
|
||||
: path === "/info/feedback/"
|
||||
? 'id="feedback-form"'
|
||||
: path === "/home-road/"
|
||||
? "return-home-directory"
|
||||
: path.startsWith("/search/")
|
||||
? "data-search-results"
|
||||
: "";
|
||||
if (marker)
|
||||
assert.ok(
|
||||
html.includes(marker),
|
||||
path + " must contain the complete server-rendered page",
|
||||
);
|
||||
console.log(`PASS: page ${path}`);
|
||||
}),
|
||||
);
|
||||
const rejected = await fetch(`${base}/api/app/not-allowed`);
|
||||
assert.equal(rejected.status, 404);
|
||||
const invalidLogin = await fetch(`${base}/api/app/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Origin: new URL(base).origin,
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(invalidLogin.status, 400);
|
||||
assert.equal((await invalidLogin.json()).code, 400);
|
||||
console.log("PASS: proxy endpoint allowlist and browser login input validation");
|
||||
Reference in New Issue
Block a user