fix(dev): resolve local stack ports before startup
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-09 21:49:25 +08:00
committed by kdletters
parent 46a254f142
commit f74717c415
8 changed files with 428 additions and 37 deletions

View File

@@ -17,7 +17,7 @@ usage() {
说明:
1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict在结构冲突时清理旧模块数据。
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;需要新启动时会先检测端口并选择最近可用端口。
5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
@@ -99,6 +99,25 @@ NODE
)
}
resolve_dev_stack_ports() {
local key
local value
while IFS='=' read -r key value; do
case "${key}" in
SPACETIME_PORT|API_PORT|WEB_PORT|ADMIN_WEB_PORT)
export "${key}=${value}"
;;
esac
done < <(
node "${REPO_ROOT}/scripts/dev-stack-port-utils.mjs" resolve-dev-stack \
"spacetime:${SPACETIME_HOST}:${SPACETIME_PORT}" \
"api:${API_TARGET_HOST}:${API_PORT}" \
"web:${WEB_HOST}:${WEB_PORT}" \
"adminWeb:${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}"
)
}
cleanup() {
local index
@@ -876,8 +895,10 @@ fi
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
ADMIN_WEB_TARGET_HOST="$(resolve_client_host "${ADMIN_WEB_HOST}")"
resolve_dev_stack_ports
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
trap cleanup EXIT INT TERM
@@ -906,24 +927,22 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
exit 1
fi
SPACETIME_PORT="$(find_nearest_available_port "${SPACETIME_HOST}" "${SPACETIME_PORT}" "SpacetimeDB")"
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
SPACETIME_START_LOG="$(spacetime_start_log_path "${SPACETIME_DATA_DIR}")"
mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")"
: >"${SPACETIME_START_LOG}"
echo "[dev:rust] 启动 spacetimedb"
(
cd "${SERVER_RS_DIR}"
# 启动前已经由脚本选定端口,避免 api-server 和 SpacetimeDB 对数据库地址认知不一致。
spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
--non-interactive
) 2>&1 | tee "${SPACETIME_START_LOG}" &
PIDS+=("$!")
NAMES+=("spacetimedb")
SPACETIME_START_LOG="${SPACETIME_DATA_DIR}/logs/dev-rust-spacetime-start.log"
mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")"
: >"${SPACETIME_START_LOG}"
echo "[dev:rust] 启动 spacetimedb"
(
cd "${SERVER_RS_DIR}"
# 当目标端口被占用时SpacetimeDB 会询问是否使用最近的可用端口;
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
printf '\n' | spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
--non-interactive
) 2>&1 | tee "${SPACETIME_START_LOG}" &
PIDS+=("$!")
NAMES+=("spacetimedb")
SPACETIME_LISTEN_ADDR="$(wait_for_spacetime_listen_addr "${SPACETIME_START_LOG}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}")"
SPACETIME_PORT="$(port_from_listen_addr "${SPACETIME_LISTEN_ADDR}")"

View File

@@ -0,0 +1,164 @@
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}`);
}
}

View File

@@ -0,0 +1,51 @@
import {createServer} from 'node:net';
import {describe, expect, it} from 'vitest';
import {
findAvailablePort,
resolveDevStackPorts,
} from './dev-stack-port-utils.mjs';
function reservePort(port) {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(port, '127.0.0.1', () => {
server.off('error', reject);
resolve(server);
});
});
}
describe('dev stack port utils', () => {
it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => {
const firstServer = await reservePort(0);
const firstPort = firstServer.address().port;
const secondServer = await reservePort(firstPort + 1);
try {
const availablePort = await findAvailablePort({
host: '127.0.0.1',
preferredPort: firstPort,
});
expect(availablePort).toBeGreaterThan(firstPort + 1);
} finally {
await Promise.all([
new Promise((resolve) => firstServer.close(resolve)),
new Promise((resolve) => secondServer.close(resolve)),
]);
}
});
it('为 npm run dev 的所有后续流程解析互不冲突的端口', async () => {
const resolvedPorts = await resolveDevStackPorts({
spacetime: {host: '127.0.0.1', preferredPort: 0},
api: {host: '127.0.0.1', preferredPort: 0},
web: {host: '127.0.0.1', preferredPort: 0},
adminWeb: {host: '127.0.0.1', preferredPort: 0},
});
expect(new Set(Object.values(resolvedPorts)).size).toBe(4);
});
});

View File

@@ -1,6 +1,11 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {
findAvailablePort,
formatPortDecision,
normalizePort,
} from './dev-stack-port-utils.mjs';
const repoRoot = process.cwd();
const shellEnvKeys = new Set(Object.keys(process.env));
@@ -121,9 +126,24 @@ const mergedEnv = {
console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`);
const webHost = '0.0.0.0';
const preferredWebPort = normalizePort(fileEnv.WEB_PORT, 3000);
const webPort = await findAvailablePort({
host: webHost,
preferredPort: preferredWebPort,
});
console.log(
formatPortDecision({
name: 'web',
host: webHost,
preferredPort: preferredWebPort,
resolvedPort: webPort,
}),
);
const child = spawn(
'node',
['scripts/vite-cli.mjs', '--port=3000', '--host=0.0.0.0', '--strictPort'],
['scripts/vite-cli.mjs', `--port=${webPort}`, `--host=${webHost}`, '--strictPort'],
{
cwd: process.cwd(),
env: mergedEnv,