feat: unify phase one creation flow

This commit is contained in:
2026-05-30 05:05:02 +08:00
parent 3a87b2d966
commit 26975644b5
33 changed files with 2037 additions and 539 deletions

View File

@@ -26,6 +26,7 @@ import {
} from './dev-utils.mjs';
const repoRoot = process.cwd();
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const serverRsDir = resolve(repoRoot, 'server-rs');
const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
@@ -245,6 +246,136 @@ function normalizeServiceName(rawName) {
throw new Error(`未知模块: ${rawName}`);
}
function resolveDevStackStatePath(root = repoRoot) {
return resolve(root, '.app/dev-stack.json');
}
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
const services = {};
for (const serviceName of SERVICE_NAMES) {
services[serviceName] = buildDevStackServiceSnapshot(
runner,
serviceName,
updatedAt,
);
}
return {
schemaVersion: 1,
command: runner.command ?? 'all',
repoRoot,
database: runner.options.database,
watch: Boolean(runner.options.watch),
updatedAt,
services,
};
}
function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) {
const service = runner.services.get(serviceName);
const runtime = service?.runtime ?? {};
const endpoint = resolveDevStackServiceEndpoint(runner, serviceName);
const isReusedSpacetime =
serviceName === 'spacetime' && runner.state.spacetimeReused;
const runtimeStatus = runtime.status ?? 'idle';
const status =
runtimeStatus === 'idle' && isReusedSpacetime ? 'reused' : runtimeStatus;
const childPid =
service?.child && Number.isInteger(service.child.pid)
? service.child.pid
: null;
return {
status,
pid:
childPid ??
runtime.pid ??
(isReusedSpacetime ? (runner.state.spacetimePid ?? null) : null),
host: runtime.host ?? endpoint.host,
port: runtime.port ?? endpoint.port,
url: runtime.url ?? endpoint.url,
command: runtime.command ?? resolveDevStackServiceCommand(runner, serviceName),
startedAt: runtime.startedAt ?? null,
updatedAt: runtime.updatedAt ?? updatedAt,
exitCode: runtime.exitCode ?? null,
signal: runtime.signal ?? null,
};
}
function resolveDevStackServiceEndpoint(runner, serviceName) {
const {options, state} = runner;
switch (serviceName) {
case 'spacetime':
return {
host: options.spacetimeHost,
port: options.spacetimePort,
url: state.spacetimeServer,
};
case 'api-server':
return {
host: options.apiHost,
port: options.apiPort,
url: state.apiTarget,
};
case 'web':
return {
host: options.webHost,
port: options.webPort,
url: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
};
case 'admin-web':
return {
host: options.adminWebHost,
port: options.adminWebPort,
url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`,
};
default:
return {
host: null,
port: null,
url: null,
};
}
}
function resolveDevStackServiceCommand(runner, serviceName) {
const {options, state} = runner;
switch (serviceName) {
case 'spacetime':
return state.spacetimeReused
? `reuse spacetime standalone ${state.spacetimeServer}`
: [
'spacetime',
'start',
'--data-dir',
options.spacetimeDataDir,
'--listen-addr',
`${options.spacetimeHost}:${options.spacetimePort}`,
'--non-interactive',
].join(' ');
case 'api-server':
return 'cargo run -p api-server --manifest-path server-rs/Cargo.toml';
case 'web':
return [
'node',
relative(repoRoot, viteCliPath),
`--port=${options.webPort}`,
`--host=${options.webHost}`,
'--strictPort',
].join(' ');
case 'admin-web':
return [
'node',
relative(adminWebDir, viteCliPath),
`--host=${options.adminWebHost}`,
`--port=${options.adminWebPort}`,
'--strictPort',
].join(' ');
default:
return null;
}
}
function requireCommand(command) {
const result = spawnSync(command, ['--version'], {
cwd: repoRoot,
@@ -374,14 +505,37 @@ function ensureRequiredFiles(command) {
}
class DevService {
constructor(name, startFn) {
constructor(name, startFn, onStateChange = null) {
this.name = name;
this.startFn = startFn;
this.onStateChange = onStateChange;
this.child = null;
this.children = [];
this.logStream = null;
this.stopping = false;
this.restartTimer = null;
this.runtime = {
status: 'idle',
pid: null,
host: null,
port: null,
url: null,
command: null,
startedAt: null,
updatedAt: null,
exitCode: null,
signal: null,
};
}
updateRuntimeState(patch) {
const updatedAt = new Date().toISOString();
this.runtime = {
...this.runtime,
...patch,
updatedAt,
};
this.onStateChange?.();
}
async start() {
@@ -389,17 +543,36 @@ class DevService {
return;
}
await this.startFn(this);
this.updateRuntimeState({status: 'starting', exitCode: null, signal: null});
try {
await this.startFn(this);
} catch (error) {
this.updateRuntimeState({status: 'failed', pid: null});
throw error;
}
}
registerChild(child) {
this.child = child;
this.updateRuntimeState({
status: 'running',
pid: Number.isInteger(child.pid) ? child.pid : null,
exitCode: null,
signal: null,
startedAt: this.runtime.startedAt ?? new Date().toISOString(),
});
child.on('exit', (code, signal) => {
if (this.logStream && !this.logStream.destroyed) {
this.logStream.end();
}
this.child = null;
this.updateRuntimeState({
status: this.stopping ? 'stopped' : code === 0 ? 'stopped' : 'failed',
pid: null,
exitCode: code ?? null,
signal: signal ?? null,
});
if (this.stopping) {
this.stopping = false;
return;
@@ -418,6 +591,9 @@ class DevService {
const processes = [this.child, ...this.children].filter(Boolean);
this.stopping = processes.length > 0;
if (processes.length > 0) {
this.updateRuntimeState({status: 'stopping'});
}
this.child = null;
this.children = [];
@@ -430,6 +606,9 @@ class DevService {
}
this.logStream = null;
this.stopping = false;
if (processes.length > 0) {
this.updateRuntimeState({status: 'stopped', pid: null});
}
}
scheduleRestart(delayMs = 250, restartFn = null, actionLabel = '重启') {
@@ -576,6 +755,7 @@ class DevRunner {
await this.resolvePorts(command);
this.registerServices();
this.printSummary(command);
this.writeDevStackState();
}
shouldValidateSpacetimeToolVersion(command) {
@@ -642,6 +822,9 @@ class DevRunner {
}
this.state.spacetimeServer = candidate;
this.state.spacetimeReused = true;
this.state.spacetimePid = Number.isInteger(pidState.pid)
? pidState.pid
: null;
const pidLabel = Number.isInteger(pidState.pid) ? ` pid=${pidState.pid}` : '';
console.log(`[dev:spacetime] 复用已启动实例${pidLabel}: ${candidate}`);
return;
@@ -727,19 +910,48 @@ class DevRunner {
}
registerServices() {
const onStateChange = () => this.writeDevStackState();
this.services.set(
'spacetime',
new DevService('spacetime', async (service) => this.startSpacetime(service)),
new DevService(
'spacetime',
async (service) => this.startSpacetime(service),
onStateChange,
),
);
this.services.set(
'api-server',
new DevService('api-server', async (service) => this.startApiServer(service)),
new DevService(
'api-server',
async (service) => this.startApiServer(service),
onStateChange,
),
);
this.services.set(
'web',
new DevService('web', async (service) => this.startWeb(service), onStateChange),
);
this.services.set('web', new DevService('web', async (service) => this.startWeb(service)));
this.services.set(
'admin-web',
new DevService('admin-web', async (service) => this.startAdminWeb(service)),
new DevService(
'admin-web',
async (service) => this.startAdminWeb(service),
onStateChange,
),
);
if (this.state.spacetimeReused) {
const spacetimeService = this.services.get('spacetime');
const endpoint = resolveDevStackServiceEndpoint(this, 'spacetime');
spacetimeService?.updateRuntimeState({
status: 'reused',
pid: this.state.spacetimePid ?? null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'spacetime'),
});
}
}
printSummary(command) {
@@ -754,6 +966,16 @@ class DevRunner {
console.log(`[dev] database: ${options.database}`);
}
writeDevStackState() {
try {
ensureParentDir(devStackStatePath);
const snapshot = buildDevStackSnapshot(this);
writeFileSync(devStackStatePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
} catch (error) {
console.warn(`[dev] 写入 ${devStackStatePath} 失败: ${error.message}`);
}
}
async startCommand(command) {
if (command === 'all') {
await this.startSpacetimeForFullStack();
@@ -827,6 +1049,17 @@ class DevRunner {
);
console.log(`[dev:spacetime] log: ${logFile}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: options.spacetimeHost,
port: options.spacetimePort,
url: this.state.spacetimeServer,
command: resolveDevStackServiceCommand(this, 'spacetime'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const env = {
...this.baseEnv,
};
@@ -880,6 +1113,11 @@ class DevRunner {
this.options.spacetimePort = port;
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${port}`;
recordSpacetimeUrl(this.options.spacetimeDataDir, this.state.spacetimeServer);
this.services.get('spacetime')?.updateRuntimeState({
host: this.options.spacetimeHost,
port,
url: this.state.spacetimeServer,
});
console.log(`[dev:spacetime] actual: ${this.state.spacetimeServer}`);
}
@@ -976,6 +1214,17 @@ class DevRunner {
console.log(
`[dev:api-server] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`,
);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: this.options.apiHost,
port: this.options.apiPort,
url: this.state.apiTarget,
command: resolveDevStackServiceCommand(this, 'api-server'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const child = spawn(
'cargo',
@@ -1018,6 +1267,7 @@ class DevRunner {
startWeb(service) {
const apiTarget = this.resolveFrontendApiTarget();
const endpoint = resolveDevStackServiceEndpoint(this, 'web');
const env = {
...this.baseEnv,
RUST_SERVER_TARGET: apiTarget,
@@ -1028,6 +1278,17 @@ class DevRunner {
};
console.log(`[dev:web] api target: ${apiTarget}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'web'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const child = spawn(
'node',
[
@@ -1053,6 +1314,7 @@ class DevRunner {
startAdminWeb(service) {
const apiTarget = this.resolveFrontendApiTarget({admin: true});
const endpoint = resolveDevStackServiceEndpoint(this, 'admin-web');
const env = {
...this.baseEnv,
ADMIN_API_TARGET: apiTarget,
@@ -1061,6 +1323,17 @@ class DevRunner {
};
console.log(`[dev:admin-web] api target: ${apiTarget}`);
service.updateRuntimeState({
status: 'starting',
pid: null,
host: endpoint.host,
port: endpoint.port,
url: endpoint.url,
command: resolveDevStackServiceCommand(this, 'admin-web'),
startedAt: new Date().toISOString(),
exitCode: null,
signal: null,
});
const child = spawn(
'node',
[
@@ -1731,12 +2004,14 @@ export {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
isSpacetimePublishPermissionError,
parseSpacetimeToolVersion,
parseArgs,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
};