623 lines
16 KiB
JavaScript
623 lines
16 KiB
JavaScript
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}`);
|
|
}
|
|
}
|