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}`);