- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
8.1 KiB
TypeScript
142 lines
8.1 KiB
TypeScript
import { cp, lstat, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createRequire } from 'node:module';
|
|
|
|
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: tsx scripts/prepare-production-runtime.ts [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');
|
|
}
|
|
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: NodeJS.ErrnoException) => {
|
|
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: 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', '.next/BUILD_ID', 'public/sw.js',
|
|
'config/production.env', 'config/secrets/m3u8.key'];
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
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: [string, RegExp][] = [
|
|
[`@img/sharp-${sharpPlatform}`, /\.node$/],
|
|
[`@img/sharp-libvips-${sharpPlatform}`, /libvips.*\.so(?:\.\d+)*$/],
|
|
];
|
|
await rm(target, { recursive: true, force: true });
|
|
await cp(source, target, {
|
|
recursive: true,
|
|
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;
|
|
if ((await lstat(path)).isSymbolicLink()) throw new Error(`Runtime source contains a symbolic link: ${path}`);
|
|
return true;
|
|
},
|
|
});
|
|
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: Record<string, string> = {}, records: Record<string, unknown> = {};
|
|
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: 'hkdj-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, 'node_modules/dotenv/package.json', 'node_modules/sharp/package.json',
|
|
'node_modules/detect-libc/package.json', 'node_modules/semver/package.json', 'node_modules/@img/colour/package.json']) {
|
|
await requireFile(target, file);
|
|
}
|
|
const runtimeRequire = createRequire(join(target, 'package.json'));
|
|
if (typeof runtimeRequire('dotenv').parse !== 'function') throw new Error('Runtime dotenv is unavailable');
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
// 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}`);
|