import {randomBytes} from 'node:crypto'; import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import {hostname, userInfo} from 'node:os'; import {dirname, resolve} from 'node:path'; import {createServer} from 'node:net'; const LINUX_DEV_PORT_RANGE_REGISTRY_ROOT = '/var/tmp/genarrative-dev-port-ranges'; const LINUX_DEV_PORT_RANGE_POOL_START = 10000; const LINUX_DEV_PORT_RANGE_POOL_END = 39999; const LINUX_DEV_PORT_RANGE_BLOCK_SIZE = 100; function toListenHosts(host) { if (host === '0.0.0.0') { return ['0.0.0.0']; } if (host === '::') { return ['::']; } return [host]; } export function normalizePort(value, fallback) { const port = Number.parseInt(String(value ?? ''), 10); if (!Number.isInteger(port) || port < 0 || port > 65535) { return fallback; } return port; } export function parsePortRangeSpec(value) { const spec = String(value ?? '').trim(); if (!spec) { return null; } const match = /^(\d+)\s*[-:]\s*(\d+)$/u.exec(spec); if (!match) { throw new Error(`端口段格式错误: ${spec},应为 start-end 形式,如 10000-10099`); } const start = normalizePort(match[1], -1); const end = normalizePort(match[2], -1); if (start < 1024 || end < 1024 || start > end) { throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`); } if (end - start + 1 < 4) { throw new Error(`端口段至少需要 4 个端口: ${spec}`); } return {start, end, label: `${start}-${end}`}; } function normalizePortRange(portRange) { if (!portRange) { return null; } if (typeof portRange === 'string') { return parsePortRangeSpec(portRange); } return parsePortRangeSpec(`${portRange.start}-${portRange.end}`); } export function getLinuxDevPortRangeRegistryPaths(env = process.env) { const registryRoot = String(env.GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR ?? '').trim() || LINUX_DEV_PORT_RANGE_REGISTRY_ROOT; return { root: registryRoot, registryPath: resolve(registryRoot, 'registry.json'), lockPath: resolve(registryRoot, 'registry.lock'), }; } export function getLinuxDevPortRangeUsername(env = process.env) { const candidates = [env.SUDO_USER, env.LOGNAME, env.USER, env.USERNAME]; for (const candidate of candidates) { const value = String(candidate ?? '').trim(); if (value) { return value; } } try { return userInfo().username; } catch { return 'unknown'; } } export function getLinuxDevPortRangeSpec(env = process.env) { return parsePortRangeSpec(env.GENARRATIVE_DEV_PORT_RANGE); } export function mapDevPortsToPortRange(portRange) { const normalizedRange = normalizePortRange(portRange); if (!normalizedRange) { return null; } return { webPort: normalizedRange.start, apiPort: normalizedRange.start + 1, spacetimePort: normalizedRange.start + 2, adminWebPort: normalizedRange.start + 3, range: normalizedRange, }; } function ensureWorldWritableDirectory(path) { mkdirSync(path, {recursive: true}); try { chmodSync(path, 0o777); } catch { // 目录已存在时若无法改权限,也不影响当前进程继续写入。 } } function randomToken(byteLength = 8) { return randomBytes(byteLength).toString('hex'); } function readJsonFile(path, fallbackValue) { if (!existsSync(path)) { return fallbackValue; } const rawText = readFileSync(path, 'utf8').trim(); if (!rawText) { return fallbackValue; } try { return JSON.parse(rawText); } catch (error) { throw new Error(`系统级端口段记录文件损坏: ${path}; ${error.message}`); } } function atomicWriteJsonFile(path, value) { const targetDir = dirname(path); ensureWorldWritableDirectory(targetDir); const tempPath = resolve( targetDir, `.tmp.${process.pid}.${Date.now()}.${randomToken()}.json`, ); writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o666, }); try { chmodSync(tempPath, 0o666); } catch { // 先写成功再说,后续 rename 之后还能补一次。 } try { renameSync(tempPath, path); } catch (error) { try { rmSync(tempPath, {force: true}); } catch { // ignore cleanup races } throw error; } try { chmodSync(path, 0o666); } catch { // 忽略权限补写失败,记录已落盘即可。 } } function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) { return false; } try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getDefaultPortRangeRegistry() { return { version: 4, updatedAt: '', allocations: {}, }; } function readLinuxPortRangeRegistry(registryPath) { const registry = readJsonFile(registryPath, getDefaultPortRangeRegistry()); if (!registry || typeof registry !== 'object' || Array.isArray(registry)) { throw new Error(`系统级端口段记录文件格式错误: ${registryPath}`); } if (!registry.allocations || typeof registry.allocations !== 'object') { registry.allocations = {}; } const normalizedAllocations = {}; for (const [key, record] of Object.entries(registry.allocations)) { if (!record || typeof record !== 'object' || Array.isArray(record)) { continue; } const username = String(record.username ?? key ?? '').trim(); const range = safeNormalizePortRange(record.range); if (!username || !range) { continue; } normalizedAllocations[username] = { username, range, claimedAt: String(record.claimedAt ?? '').trim(), updatedAt: String(record.updatedAt ?? record.claimedAt ?? '').trim(), source: String(record.source ?? 'auto').trim() || 'auto', }; } registry.version = 4; registry.allocations = normalizedAllocations; return registry; } function safeNormalizePortRange(portRange) { try { return normalizePortRange(portRange); } catch { return null; } } function rangesOverlap(left, right) { return left.start <= right.end && right.start <= left.end; } function findRangeConflict(registry, portRange, excludingUsername = '') { const rangeToCheck = safeNormalizePortRange(portRange); if (!rangeToCheck) { return null; } for (const [username, record] of Object.entries(registry.allocations ?? {})) { if (username === excludingUsername) { continue; } const recordRange = safeNormalizePortRange(record?.range); if (!recordRange || !rangesOverlap(recordRange, rangeToCheck)) { continue; } return {username, record, range: recordRange}; } return null; } function nextLinuxPortRangeCandidate(registry) { for ( let start = LINUX_DEV_PORT_RANGE_POOL_START; start <= LINUX_DEV_PORT_RANGE_POOL_END; start += LINUX_DEV_PORT_RANGE_BLOCK_SIZE ) { const end = start + LINUX_DEV_PORT_RANGE_BLOCK_SIZE - 1; if (end > LINUX_DEV_PORT_RANGE_POOL_END) { break; } const label = `${start}-${end}`; if (!findRangeConflict(registry, {start, end, label})) { return {start, end, label}; } } return null; } async function withLinuxPortRangeRegistryLock(lockPath, action) { ensureWorldWritableDirectory(dirname(lockPath)); while (true) { try { writeFileSync( lockPath, JSON.stringify( { pid: process.pid, hostname: hostname(), acquiredAt: new Date().toISOString(), }, null, 2, ), {encoding: 'utf8', flag: 'wx', mode: 0o666}, ); break; } catch (error) { if (error?.code !== 'EEXIST') { throw error; } let lockRecord = null; try { lockRecord = readJsonFile(lockPath, null); } catch { lockRecord = null; } if (!lockRecord || !isProcessAlive(lockRecord.pid)) { try { rmSync(lockPath, {force: true}); } catch { // ignore cleanup races } continue; } await sleep(50); } } try { return await action(); } finally { try { rmSync(lockPath, {force: true}); } catch { // ignore cleanup races } } } export async function reserveLinuxDevPortRange({ env = process.env, username = getLinuxDevPortRangeUsername(env), requestedRange = getLinuxDevPortRangeSpec(env), registryPath = getLinuxDevPortRangeRegistryPaths(env).registryPath, lockPath = getLinuxDevPortRangeRegistryPaths(env).lockPath, } = {}) { if (process.platform !== 'linux') { return null; } const normalizedRequestedRange = normalizePortRange(requestedRange); const now = new Date().toISOString(); return await withLinuxPortRangeRegistryLock(lockPath, async () => { const registry = readLinuxPortRangeRegistry(registryPath); const current = registry.allocations[username]; if (current) { registry.updatedAt = now; registry.allocations[username] = { ...current, username, range: current.range, updatedAt: now, }; atomicWriteJsonFile(registryPath, registry); return registry.allocations[username]; } const rangeToUse = normalizedRequestedRange || nextLinuxPortRangeCandidate(registry); if (!rangeToUse) { throw new Error( `无法为 Linux dev 分配端口段,范围 ${LINUX_DEV_PORT_RANGE_POOL_START}-${LINUX_DEV_PORT_RANGE_POOL_END} 已耗尽`, ); } const conflict = findRangeConflict(registry, rangeToUse.label, username); if (conflict) { if (conflict.range?.label === rangeToUse.label) { throw new Error( `Linux 端口段 ${rangeToUse.label} 已被用户 ${conflict.username} 占用,请换一段再启动`, ); } throw new Error( `Linux 端口段 ${rangeToUse.label} 已与用户 ${conflict.username} 的端口段 ${conflict.range?.label} 重叠,请换一段再启动`, ); } registry.allocations[username] = { username, range: rangeToUse, source: normalizedRequestedRange ? 'manual' : 'auto', claimedAt: now, updatedAt: now, }; registry.updatedAt = now; atomicWriteJsonFile(registryPath, registry); return registry.allocations[username]; }); } export async function releaseLinuxDevPortRange({ env = process.env, username = getLinuxDevPortRangeUsername(env), registryPath = getLinuxDevPortRangeRegistryPaths(env).registryPath, lockPath = getLinuxDevPortRangeRegistryPaths(env).lockPath, } = {}) { if (process.platform !== 'linux') { return false; } return await withLinuxPortRangeRegistryLock(lockPath, async () => { const registry = readLinuxPortRangeRegistry(registryPath); if (!registry.allocations[username]) { return false; } delete registry.allocations[username]; registry.updatedAt = new Date().toISOString(); atomicWriteJsonFile(registryPath, registry); return true; }); } export async function isPortAvailable({host, port}) { if (port === 0) { return true; } const listenHosts = toListenHosts(host); for (const listenHost of listenHosts) { const available = await new Promise((resolve) => { const server = createServer(); server.unref(); server.once('error', () => resolve(false)); server.listen({host: listenHost, port}, () => { server.close(() => resolve(true)); }); }); if (!available) { return false; } } return true; } export async function findAvailablePort({ host, preferredPort, reservedPorts = new Set(), maxAttempts = null, portRange = null, }) { const range = normalizePortRange(portRange); const startPort = normalizePort(preferredPort, 0); if (startPort === 0 && range) { return await findAvailablePort({ host, preferredPort: range.start, reservedPorts, maxAttempts: range.end - range.start, portRange: range, }); } if (startPort === 0) { return await reserveEphemeralPort(host, reservedPorts); } if (range && (startPort < range.start || startPort > range.end)) { throw new Error(`端口 ${startPort} 不在允许端口段 ${range.label} 内`); } const boundedAttempts = range ? Number.isFinite(maxAttempts) ? Math.min(Math.max(0, maxAttempts), range.end - startPort) : range.end - startPort : Number.isFinite(maxAttempts) ? Math.max(0, maxAttempts) : 200; for (let offset = 0; offset <= boundedAttempts; offset += 1) { const candidate = startPort + offset; if (candidate > 65535 || (range && candidate > range.end)) { break; } if (reservedPorts.has(candidate)) { continue; } if (await isPortAvailable({host, port: candidate})) { return candidate; } } const rangeHint = range ? `,允许端口段 ${range.label}` : ''; throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口${rangeHint}`); } async function reserveEphemeralPort(host, reservedPorts) { for (let attempt = 0; attempt < 20; attempt += 1) { const port = await new Promise((resolve, reject) => { const server = createServer(); server.unref(); server.once('error', reject); server.listen({host, port: 0}, () => { const address = server.address(); const resolvedPort = typeof address === 'object' && address ? address.port : 0; server.close(() => resolve(resolvedPort)); }); }); if (typeof port === 'number' && port > 0 && !reservedPorts.has(port)) { return port; } } throw new Error(`无法为 ${host} 分配临时可用端口`); } export async function resolveDevStackPorts(config) { const reservedPorts = new Set(); const entries = [ ['spacetime', config.spacetime], ['api', config.api], ['web', config.web], ['adminWeb', config.adminWeb], ].filter(([, portConfig]) => Boolean(portConfig)); const result = {}; for (const [name, portConfig] of entries) { const resolvedPort = await findAvailablePort({ host: portConfig.host, preferredPort: portConfig.preferredPort, reservedPorts, portRange: portConfig.portRange, }); reservedPorts.add(resolvedPort); result[name] = resolvedPort; } return result; } export function formatPortDecision({name, host, preferredPort, resolvedPort}) { if (preferredPort === resolvedPort && preferredPort !== 0) { return `[dev:ports] ${name}: ${host}:${resolvedPort} 可用`; } return `[dev:ports] ${name}: ${host}:${preferredPort} 不可用,改用 ${host}:${resolvedPort}`; } function parseCliPortConfig(rawArgs) { const config = {}; for (const rawArg of rawArgs) { const [name, host, rawPreferredPort] = rawArg.split(':'); if (!name || !host || rawPreferredPort == null) { throw new Error(`端口配置参数格式错误: ${rawArg}`); } config[name] = { host, preferredPort: normalizePort(rawPreferredPort, 0), }; } return config; } function envKeyForPortName(name) { return `${name.replace(/[A-Z]/gu, (letter) => `_${letter}`).toUpperCase()}_PORT`; } if (process.argv[2] === 'resolve-dev-stack') { const config = parseCliPortConfig(process.argv.slice(3)); const resolvedPorts = await resolveDevStackPorts(config); for (const [name, resolvedPort] of Object.entries(resolvedPorts)) { const portConfig = config[name]; console.error( formatPortDecision({ name, host: portConfig.host, preferredPort: portConfig.preferredPort, resolvedPort, }), ); console.log(`${envKeyForPortName(name)}=${resolvedPort}`); } }