import {createServer} from 'node:net'; 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 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 = 200}) { const startPort = normalizePort(preferredPort, 0); if (startPort === 0) { return await reserveEphemeralPort(host, reservedPorts); } for (let offset = 0; offset <= maxAttempts; offset += 1) { const candidate = startPort + offset; if (candidate > 65535) { break; } if (reservedPorts.has(candidate)) { continue; } if (await isPortAvailable({host, port: candidate})) { return candidate; } } throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口`); } 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, }); 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}`); } }