merge origin/master into codex/wechat-mini-program-virtual-payment

This commit is contained in:
kdletters
2026-05-31 23:00:43 +08:00
278 changed files with 2904 additions and 2129 deletions

View File

@@ -1,5 +1,22 @@
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'];
@@ -21,6 +38,416 @@ export function normalizePort(value, 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;
@@ -46,16 +473,45 @@ export async function isPortAvailable({host, port}) {
return true;
}
export async function findAvailablePort({host, preferredPort, reservedPorts = new Set(), maxAttempts = 200}) {
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);
}
for (let offset = 0; offset <= maxAttempts; offset += 1) {
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) {
if (candidate > 65535 || (range && candidate > range.end)) {
break;
}
@@ -68,7 +524,8 @@ export async function findAvailablePort({host, preferredPort, reservedPorts = ne
}
}
throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口`);
const rangeHint = range ? `,允许端口段 ${range.label}` : '';
throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口${rangeHint}`);
}
async function reserveEphemeralPort(host, reservedPorts) {
@@ -107,6 +564,7 @@ export async function resolveDevStackPorts(config) {
host: portConfig.host,
preferredPort: portConfig.preferredPort,
reservedPorts,
portRange: portConfig.portRange,
});
reservedPorts.add(resolvedPort);
result[name] = resolvedPort;

View File

@@ -1,7 +1,13 @@
import {createServer} from 'node:net';
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {describe, expect, it} from 'vitest';
import {
findAvailablePort,
mapDevPortsToPortRange,
parsePortRangeSpec,
reserveLinuxDevPortRange,
resolveDevStackPorts,
} from './dev-stack-port-utils.mjs';
@@ -18,6 +24,20 @@ function reservePort(port) {
}
describe('dev stack port utils', () => {
it('解析端口段并映射到四个 dev 端口', () => {
expect(parsePortRangeSpec('10000-10099')).toEqual({
start: 10000,
end: 10099,
label: '10000-10099',
});
expect(mapDevPortsToPortRange('10000-10099')).toMatchObject({
webPort: 10000,
apiPort: 10001,
spacetimePort: 10002,
adminWebPort: 10003,
});
});
it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => {
const firstServer = await reservePort(0);
const firstPort = firstServer.address().port;
@@ -38,6 +58,16 @@ describe('dev stack port utils', () => {
}
});
it('端口查找不会越过 Linux 用户端口段', async () => {
await expect(
findAvailablePort({
host: '127.0.0.1',
preferredPort: 9999,
portRange: {start: 10000, end: 10099, label: '10000-10099'},
}),
).rejects.toThrow('不在允许端口段');
});
it('为 npm run dev 的所有后续流程解析互不冲突的端口', async () => {
const resolvedPorts = await resolveDevStackPorts({
spacetime: {host: '127.0.0.1', preferredPort: 0},
@@ -48,4 +78,191 @@ describe('dev stack port utils', () => {
expect(new Set(Object.values(resolvedPorts)).size).toBe(4);
});
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {
const rangeStart = 10000;
const rangeEnd = 10300;
const reservedPorts = new Set(
Array.from({length: 201}, (_, index) => rangeStart + index),
);
const availablePort = await findAvailablePort({
host: '127.0.0.1',
preferredPort: rangeStart,
reservedPorts,
portRange: {start: rangeStart, end: rangeEnd, label: `${rangeStart}-${rangeEnd}`},
});
expect(availablePort).toBeGreaterThan(rangeStart + 200);
expect(availablePort).toBeLessThanOrEqual(rangeEnd);
});
const linuxIt = process.platform === 'linux' ? it : it.skip;
linuxIt('Linux 未手动指定端口段时从 10000 开始按 100 端口块自动分配', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
const aliceAllocation = await reserveLinuxDevPortRange({
env: {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'alice',
requestedRange: null,
registryPath,
lockPath,
});
const bobAllocation = await reserveLinuxDevPortRange({
env: {
USER: 'bob',
LOGNAME: 'bob',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'bob',
requestedRange: null,
registryPath,
lockPath,
});
expect(aliceAllocation.range.label).toBe('10000-10099');
expect(aliceAllocation.source).toBe('auto');
expect(bobAllocation.range.label).toBe('10100-10199');
expect(bobAllocation.source).toBe('auto');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
linuxIt('Linux 系统级端口段记录会阻止两个用户拿到同一段', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
const baseEnv = {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
GENARRATIVE_DEV_PORT_RANGE: '10000-10099',
};
const aliceAllocation = await reserveLinuxDevPortRange({
env: baseEnv,
username: 'alice',
registryPath,
lockPath,
});
expect(aliceAllocation.range.label).toBe('10000-10099');
const registryAfterAlice = JSON.parse(readFileSync(registryPath, 'utf8'));
expect(registryAfterAlice.allocations.alice.range.label).toBe('10000-10099');
await expect(
reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'bob',
LOGNAME: 'bob',
},
username: 'bob',
requestedRange: '10000-10099',
registryPath,
lockPath,
}),
).rejects.toThrow('已被用户 alice 占用');
const aliceReuse = await reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'alice',
LOGNAME: 'alice',
},
username: 'alice',
registryPath,
lockPath,
});
expect(aliceReuse.range.label).toBe('10000-10099');
expect(aliceReuse.source).toBe('manual');
expect(
(
await reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '10100-10199',
},
username: 'alice',
requestedRange: '10100-10199',
registryPath,
lockPath,
})
).range.label,
).toBe('10000-10099');
const registryAfterReuse = JSON.parse(readFileSync(registryPath, 'utf8'));
expect(registryAfterReuse.allocations.alice.range.label).toBe('10000-10099');
const bobAllocation = await reserveLinuxDevPortRange({
env: {
...baseEnv,
USER: 'bob',
LOGNAME: 'bob',
GENARRATIVE_DEV_PORT_RANGE: '10100-10199',
},
username: 'bob',
requestedRange: '10100-10199',
registryPath,
lockPath,
});
expect(bobAllocation.range.label).toBe('10100-10199');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
linuxIt('Linux 同一用户第二个 dev 会话会复用同一用户段并继续在段内漂移', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
const baseEnv = {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
GENARRATIVE_DEV_PORT_RANGE: '10000-10099',
};
const first = await reserveLinuxDevPortRange({
env: baseEnv,
username: 'alice',
registryPath,
lockPath,
});
const second = await reserveLinuxDevPortRange({
env: baseEnv,
username: 'alice',
registryPath,
lockPath,
});
expect(first.range.label).toBe('10000-10099');
expect(second.range.label).toBe('10000-10099');
expect(second.username).toBe('alice');
expect(JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range.label).toBe(
'10000-10099',
);
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
});

View File

@@ -15,7 +15,11 @@ import {fileURLToPath} from 'node:url';
import {
formatPortDecision,
getLinuxDevPortRangeRegistryPaths,
getLinuxDevPortRangeUsername,
mapDevPortsToPortRange,
normalizePort,
reserveLinuxDevPortRange,
resolveDevStackPorts,
} from './dev-stack-port-utils.mjs';
import {
@@ -59,6 +63,7 @@ function usage() {
--spacetime-port <port> SpacetimeDB 端口
--spacetime-data-dir <path> SpacetimeDB 本地数据目录
--database <name> SpacetimeDB 数据库名
--port-range <start-end> Linux 用户端口段,手动指定示例 10000-10099默认自动从 10000-10099 起分配
--watch 文件改动后刷新/重启对应模块
--no-interactive 关闭交互式手动命令
@@ -109,6 +114,7 @@ function parseArgs(argv, baseEnv) {
spacetimePort: normalizePort(env.SPACETIME_PORT, 3101),
spacetimeDataDir: resolve(serverRsDir, '.spacetimedb/local/data'),
spacetimeServerUrl: String(env.GENARRATIVE_SPACETIME_SERVER_URL ?? '').trim(),
portRangeSpec: String(env.GENARRATIVE_DEV_PORT_RANGE ?? '').trim(),
database:
readLocalSpacetimeDatabase() ||
String(env.GENARRATIVE_SPACETIME_DATABASE ?? '').trim() ||
@@ -184,6 +190,10 @@ function parseArgs(argv, baseEnv) {
options.database = readValue();
explicitOptions.add('database');
break;
case '--port-range':
options.portRangeSpec = readValue();
explicitOptions.add('portRangeSpec');
break;
case '--log':
options.apiLog = readValue();
break;
@@ -549,6 +559,8 @@ class DevRunner {
adminWebTargetHost: resolveClientHost(options.adminWebHost),
spacetimeServer: initialSpacetimeServer,
apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`,
portRange: null,
portRangeReservation: null,
};
this.services = new Map();
this.watchers = [];
@@ -572,12 +584,57 @@ class DevRunner {
ensureSpacetimeToolVersionMatchesWorkspace();
}
await this.prepareLinuxPortRange(command);
await this.tryReuseExistingSpacetime(command);
await this.resolvePorts(command);
this.registerServices();
this.printSummary(command);
}
async prepareLinuxPortRange(command) {
if (process.platform !== 'linux') {
return;
}
const requestedRange = String(this.options.portRangeSpec ?? '').trim();
const allocation = await reserveLinuxDevPortRange({
env: {
...this.baseEnv,
GENARRATIVE_DEV_PORT_RANGE: requestedRange,
},
username: getLinuxDevPortRangeUsername(this.baseEnv),
});
if (!allocation) {
return;
}
this.state.portRangeReservation = allocation;
this.state.portRange = allocation.range;
this.baseEnv.GENARRATIVE_DEV_PORT_RANGE = allocation.range.label;
const mappedPorts = mapDevPortsToPortRange(allocation.range);
if (!mappedPorts) {
return;
}
if (!this.explicitOptions.has('webPort')) {
this.options.webPort = mappedPorts.webPort;
}
if (!this.explicitOptions.has('apiPort')) {
this.options.apiPort = mappedPorts.apiPort;
}
if (!this.explicitOptions.has('spacetimePort')) {
this.options.spacetimePort = mappedPorts.spacetimePort;
}
if (!this.explicitOptions.has('adminWebPort')) {
this.options.adminWebPort = mappedPorts.adminWebPort;
}
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`;
this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`;
}
shouldValidateSpacetimeToolVersion(command) {
if (command === 'spacetime') {
return true;
@@ -626,6 +683,10 @@ class DevRunner {
]);
for (const candidate of candidates) {
if (!this.isCandidateSpacetimeWithinAssignedRange(candidate)) {
continue;
}
const pingUrl = buildUrl(candidate, '/v1/ping');
if (!pingUrl || !(await isHttpReady(pingUrl))) {
continue;
@@ -653,6 +714,21 @@ class DevRunner {
);
}
isCandidateSpacetimeWithinAssignedRange(candidateUrl) {
const range = this.state.portRange;
if (!range) {
return true;
}
try {
const url = new URL(candidateUrl);
const port = Number(url.port);
return Number.isInteger(port) && port >= range.start && port <= range.end;
} catch {
return false;
}
}
async resolvePorts(command) {
const {options} = this;
const portConfig = {};
@@ -662,6 +738,7 @@ class DevRunner {
portConfig.spacetime = {
host: options.spacetimeHost,
preferredPort: options.spacetimePort,
portRange: this.state.portRange,
};
}
}
@@ -670,6 +747,7 @@ class DevRunner {
portConfig.api = {
host: options.apiHost,
preferredPort: options.apiPort,
portRange: this.state.portRange,
};
}
@@ -677,6 +755,7 @@ class DevRunner {
portConfig.web = {
host: options.webHost,
preferredPort: options.webPort,
portRange: this.state.portRange,
};
}
@@ -684,6 +763,7 @@ class DevRunner {
portConfig.adminWeb = {
host: options.adminWebHost,
preferredPort: options.adminWebPort,
portRange: this.state.portRange,
};
}
@@ -747,6 +827,15 @@ class DevRunner {
console.log(`[dev] repo: ${repoRoot}`);
console.log(`[dev] command: ${command}`);
console.log(`[dev] watch: ${options.watch ? 'on' : 'off'}`);
if (state.portRange) {
const owner =
state.portRangeReservation?.username ||
getLinuxDevPortRangeUsername(this.baseEnv);
console.log(`[dev] port-range: ${state.portRange.label} (${owner})`);
console.log(
`[dev] port-range-registry: ${getLinuxDevPortRangeRegistryPaths(this.baseEnv).registryPath}`,
);
}
console.log(`[dev] web: http://127.0.0.1:${options.webPort}`);
console.log(`[dev] admin web: http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`);
console.log(`[dev] api-server: ${state.apiTarget}`);

View File

@@ -35,6 +35,8 @@ function workspaceSpacetimeVersionForTest() {
}
describe('dev scheduler argument routing', () => {
const linuxTest = process.platform === 'linux' ? test : test.skip;
test('完整 dev 栈覆盖前端代理到本次解析出的 api-server 地址', () => {
const {command, explicitOptions, options} = parseArgs([], {
GENARRATIVE_API_PORT: '8090',
@@ -88,6 +90,67 @@ describe('dev scheduler argument routing', () => {
'http://127.0.0.1:3100',
);
});
linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const {command, explicitOptions, options} = parseArgs([], {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
});
const runner = new DevRunner(options, {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
}, explicitOptions);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange.label).toBe('22000-22099');
expect(runner.options.webPort).toBe(22000);
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const {command, explicitOptions, options} = parseArgs([], {
USER: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
});
const runner = new DevRunner(options, {
USER: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
}, explicitOptions);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange).toBeNull();
expect(runner.options.webPort).toBe(3000);
expect(runner.options.apiPort).toBe(8082);
expect(runner.options.spacetimePort).toBe(3101);
expect(runner.options.adminWebPort).toBe(3102);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
});
describe('dev scheduler api-server env', () => {
@@ -252,18 +315,18 @@ describe('dev scheduler watch routing', () => {
describe('dev scheduler spacetime refresh', () => {
test('解析 spacetime --version 输出里的 tool version', () => {
const version = parseSpacetimeToolVersion(`
A new version of SpacetimeDB is available: v2.2.0 (current: v2.1.0)
spacetimedb tool version 2.2.0; spacetimedb-lib version 2.2.0;
A new version of SpacetimeDB is available: v2.3.0 (current: v2.2.0)
spacetimedb tool version 2.3.0; spacetimedb-lib version 2.3.0;
`);
expect(version).toBe('2.2.0');
expect(version).toBe('2.3.0');
});
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.1.0',
workspaceVersion: '2.2.0',
workspaceVersion: '2.3.0',
}),
).toThrow('procedure 返回值 BSATN 反序列化失败');
});