Files
Genarrative/scripts/dev-node.mjs

549 lines
16 KiB
JavaScript

import {spawn, spawnSync} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
const serverRsRoot = fileURLToPath(new URL('../server-rs/', import.meta.url));
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
const serverTsxCliPath = fileURLToPath(
new URL('../server-node/node_modules/tsx/dist/cli.mjs', import.meta.url),
);
const serverTsxLoaderPath = fileURLToPath(
new URL('../server-node/node_modules/tsx/dist/loader.mjs', import.meta.url),
);
const serverTsxLoaderUrl = pathToFileURL(serverTsxLoaderPath).href;
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
const spacetimeConfigPath = fileURLToPath(new URL('../spacetime.json', import.meta.url));
const spacetimeLocalConfigPath = fileURLToPath(new URL('../spacetime.local.json', import.meta.url));
const bundledNodePath = fileURLToPath(
new URL('../.tools/node-v22.22.2-win-x64/node.exe', import.meta.url),
);
const bundledNpmCliPath = fileURLToPath(
new URL('../.tools/node-v22.22.2-win-x64/node_modules/npm/bin/npm-cli.js', import.meta.url),
);
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative';
const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev';
const DEFAULT_RUST_API_HOST = '127.0.0.1';
const DEFAULT_RUST_API_PORT = '3100';
const DEFAULT_SPACETIME_SERVER_URL = 'http://127.0.0.1:3001';
const DEFAULT_SPACETIME_DATABASE = 'genarrative-dev';
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
const spacetimeModulePath = path.join(serverRsRoot, 'crates', 'spacetime-module');
const spacetimeRustBindingsOutDir = path.join(
serverRsRoot,
'crates',
'spacetime-client',
'src',
'module_bindings',
);
function parseEnvContents(contents) {
return contents
.split(/\r?\n/u)
.reduce((envMap, rawLine) => {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
return envMap;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex < 0) {
return envMap;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
envMap[key] = value;
return envMap;
}, {});
}
function readEnvFile(filePath) {
if (!existsSync(filePath)) {
return {};
}
return parseEnvContents(readFileSync(filePath, 'utf8'));
}
function readJsonFile(filePath) {
if (!existsSync(filePath)) {
return null;
}
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function resolveDatabaseProbeTarget(databaseUrl) {
const trimmed = databaseUrl.trim();
if (!trimmed || !/^postgres(?:ql)?:\/\//u.test(trimmed)) {
return null;
}
try {
const url = new URL(trimmed);
return {
host: url.hostname === '0.0.0.0' ? '127.0.0.1' : url.hostname,
port: Number(url.port || 5432),
};
} catch {
return null;
}
}
function checkTcpReachable(target, timeoutMs = 1500) {
return new Promise((resolve) => {
const socket = net.createConnection(target);
let settled = false;
const finish = (result) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
});
}
function resolveServerTarget(serverAddr) {
const trimmed = serverAddr.trim();
if (!trimmed) {
return 'http://127.0.0.1:8081';
}
if (/^https?:\/\//u.test(trimmed)) {
try {
const url = new URL(trimmed);
if (url.hostname === '0.0.0.0') {
url.hostname = '127.0.0.1';
}
return url.toString().replace(/\/$/u, '');
} catch {
return trimmed.replace(/\/$/u, '');
}
}
if (trimmed.startsWith(':')) {
return `http://127.0.0.1${trimmed}`;
}
if (trimmed.startsWith('0.0.0.0:')) {
return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`;
}
return `http://${trimmed}`;
}
function redactDatabaseUrl(databaseUrl) {
const trimmed = `${databaseUrl || ''}`.trim();
if (!trimmed) {
return '[missing]';
}
if (trimmed.startsWith('pg-mem://')) {
return trimmed;
}
try {
const url = new URL(trimmed);
const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres';
const portSuffix = url.port ? `:${url.port}` : '';
return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`;
} catch {
return '[configured]';
}
}
function resolvePathEnvKey(envMap) {
return Object.keys(envMap).find((key) => key.toLowerCase() === 'path') || 'PATH';
}
function prependEnvPath(envMap, nextEntry) {
const pathKey = resolvePathEnvKey(envMap);
const currentValue = envMap[pathKey] || '';
const normalizedEntry = path.resolve(nextEntry);
const segments = currentValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.filter((entry) => {
try {
return path.resolve(entry) !== normalizedEntry;
} catch {
return entry !== nextEntry;
}
});
envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter);
}
function resolveSpacetimeCommand() {
const command = process.platform === 'win32' ? 'where' : 'which';
const result = spawnSync(command, ['spacetime'], {
cwd: repoRoot,
env: mergedEnv,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status !== 0) {
return null;
}
const firstLine = `${result.stdout || ''}`
.split(/\r?\n/u)
.map((line) => line.trim())
.find(Boolean);
return firstLine || 'spacetime';
}
function runRequiredCommand(command, args, label) {
const result = spawnSync(command, args, {
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
});
if (result.status !== 0) {
console.error(`[dev:node] ${label} failed with exit code ${result.status ?? 1}`);
process.exit(result.status ?? 1);
}
}
function ensureSpacetimeSchemaReady() {
const spacetimeCommand = resolveSpacetimeCommand();
if (!spacetimeCommand) {
console.error(
'[dev:node] Missing `spacetime` CLI. Install or expose it in PATH before starting local dev.',
);
process.exit(1);
}
const spacetimeServerUrl = `${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || ''}`.trim();
const spacetimeDatabase = `${mergedEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
if (!spacetimeServerUrl || !spacetimeDatabase) {
console.error(
'[dev:node] Missing GENARRATIVE_SPACETIME_SERVER_URL or GENARRATIVE_SPACETIME_DATABASE, cannot publish local schema.',
);
process.exit(1);
}
console.log(
`[dev:node] Publishing spacetime-module to ${spacetimeDatabase} (${spacetimeServerUrl}) before Rust api-server starts...`,
);
runRequiredCommand(
spacetimeCommand,
[
'publish',
spacetimeDatabase,
'--server',
spacetimeServerUrl,
'--module-path',
spacetimeModulePath,
'--yes',
...(mergedEnv.GENARRATIVE_SPACETIME_DELETE_DATA_ON_CONFLICT === '1'
? ['--delete-data=on-conflict']
: []),
],
'spacetime publish',
);
console.log('[dev:node] Generating Rust Spacetime bindings before Rust api-server starts...');
runRequiredCommand(
spacetimeCommand,
[
'generate',
'--no-config',
'--lang',
'rust',
'--out-dir',
spacetimeRustBindingsOutDir,
'--module-path',
spacetimeModulePath,
'--include-private',
'--yes',
],
'spacetime generate (rust)',
);
}
const exampleEnv = readEnvFile(envExamplePath);
const localEnv = readEnvFile(envLocalPath);
const spacetimeConfig = readJsonFile(spacetimeConfigPath);
const spacetimeLocalConfig = readJsonFile(spacetimeLocalConfigPath);
const mergedEnv = {
...exampleEnv,
...localEnv,
...process.env,
};
const runtimeNodePath = existsSync(bundledNodePath)
? bundledNodePath
: process.execPath;
const runtimeNpmCliPath = existsSync(bundledNpmCliPath)
? bundledNpmCliPath
: '';
const runtimeNodeDir = path.dirname(runtimeNodePath);
mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot;
mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081';
mergedEnv.NODE_SERVER_TARGET =
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
mergedEnv.GENARRATIVE_API_HOST =
mergedEnv.GENARRATIVE_API_HOST || DEFAULT_RUST_API_HOST;
mergedEnv.GENARRATIVE_API_PORT =
mergedEnv.GENARRATIVE_API_PORT || DEFAULT_RUST_API_PORT;
mergedEnv.GENARRATIVE_API_TARGET =
mergedEnv.GENARRATIVE_API_TARGET ||
`http://${mergedEnv.GENARRATIVE_API_HOST}:${mergedEnv.GENARRATIVE_API_PORT}`;
mergedEnv.GENARRATIVE_INTERNAL_API_SECRET =
mergedEnv.GENARRATIVE_INTERNAL_API_SECRET || DEFAULT_INTERNAL_API_SECRET;
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL =
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || DEFAULT_SPACETIME_SERVER_URL;
mergedEnv.GENARRATIVE_SPACETIME_DATABASE =
mergedEnv.GENARRATIVE_SPACETIME_DATABASE ||
spacetimeLocalConfig?.database ||
spacetimeConfig?.database ||
DEFAULT_SPACETIME_DATABASE;
mergedEnv.DATABASE_URL =
mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL;
mergedEnv.VITE_DEV_HOST = mergedEnv.VITE_DEV_HOST || '127.0.0.1';
prependEnvPath(mergedEnv, runtimeNodeDir);
mergedEnv.npm_config_scripts_prepend_node_path = 'true';
const exampleDatabaseUrl = `${exampleEnv.DATABASE_URL || ''}`.trim();
const localDatabaseUrl = `${localEnv.DATABASE_URL || ''}`.trim();
const processDatabaseUrl = `${process.env.DATABASE_URL || ''}`.trim();
const exampleSpacetimeDatabase = `${exampleEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
const localSpacetimeDatabase = `${localEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
const processSpacetimeDatabase = `${process.env.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
const hasExplicitDatabaseUrl =
Boolean(processDatabaseUrl) ||
(Boolean(localDatabaseUrl) && localDatabaseUrl !== exampleDatabaseUrl);
const hasExplicitSpacetimeDatabase =
Boolean(processSpacetimeDatabase) ||
(Boolean(localSpacetimeDatabase) && localSpacetimeDatabase !== exampleSpacetimeDatabase);
// 本地开发默认跟随仓库当前的 Spacetime 数据库名,只有显式覆盖时才尊重环境变量。
if (!hasExplicitSpacetimeDatabase) {
mergedEnv.GENARRATIVE_SPACETIME_DATABASE =
spacetimeLocalConfig?.database ||
spacetimeConfig?.database ||
DEFAULT_SPACETIME_DATABASE;
}
if (!hasExplicitDatabaseUrl) {
const databaseProbeTarget = resolveDatabaseProbeTarget(mergedEnv.DATABASE_URL);
if (databaseProbeTarget) {
const isReachable = await checkTcpReachable(databaseProbeTarget);
if (!isReachable) {
console.warn(
`[dev:node] PostgreSQL unavailable at ${databaseProbeTarget.host}:${databaseProbeTarget.port}; falling back to ${DEV_MEMORY_DATABASE_URL} for local dev.`,
);
console.warn(
'[dev:node] Current session will use in-memory persistence only. Set DATABASE_URL in .env.local to restore PostgreSQL-backed runtime data.',
);
mergedEnv.DATABASE_URL = DEV_MEMORY_DATABASE_URL;
}
}
}
console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`);
console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`);
console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`);
console.log(`[dev:node] GENARRATIVE_API_TARGET=${mergedEnv.GENARRATIVE_API_TARGET}`);
console.log('[dev:node] GENARRATIVE_INTERNAL_API_SECRET=[configured]');
console.log(
`[dev:node] GENARRATIVE_SPACETIME_SERVER_URL=${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
);
console.log(
`[dev:node] GENARRATIVE_SPACETIME_DATABASE=${mergedEnv.GENARRATIVE_SPACETIME_DATABASE}`,
);
console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`);
console.log(`[dev:node] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`);
console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`);
ensureSpacetimeSchemaReady();
const children = new Set();
let shuttingDown = false;
let pendingExitCode = 0;
function stopChild(child) {
if (!child || child.exitCode !== null) {
return;
}
child.kill('SIGTERM');
setTimeout(() => {
if (child.exitCode === null) {
child.kill('SIGKILL');
}
}, 2000).unref();
}
function stopAllChildren() {
for (const child of children) {
stopChild(child);
}
}
function finalizeExit(code = 0) {
pendingExitCode = code;
if (children.size === 0) {
process.exit(pendingExitCode);
}
}
function requestShutdown(code = 0) {
if (!shuttingDown) {
shuttingDown = true;
pendingExitCode = code;
stopAllChildren();
}
finalizeExit(pendingExitCode);
}
function registerChild(name, child, siblingProvider) {
children.add(child);
child.on('error', (error) => {
console.error(`[dev:node] ${name} failed to start`, error);
requestShutdown(1);
});
child.on('exit', (code, signal) => {
children.delete(child);
if (!shuttingDown) {
const resolvedExitCode = code ?? 1;
const signalSuffix = signal ? ` (${signal})` : '';
console.error(
`[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`,
);
const sibling = siblingProvider();
if (sibling) {
stopChild(sibling);
}
requestShutdown(resolvedExitCode);
return;
}
finalizeExit(pendingExitCode);
});
}
const serverProcess = existsSync(serverTsxLoaderPath)
? spawn(runtimeNodePath, ['--watch', '--import', serverTsxLoaderUrl, 'src/server.ts'], {
cwd: serverRoot,
env: mergedEnv,
stdio: 'inherit',
})
: existsSync(serverTsxCliPath)
? spawn(runtimeNodePath, [serverTsxCliPath, 'watch', 'src/server.ts'], {
cwd: serverRoot,
env: mergedEnv,
stdio: 'inherit',
})
: runtimeNpmCliPath
? spawn(runtimeNodePath, [runtimeNpmCliPath, 'run', 'dev'], {
cwd: serverRoot,
env: mergedEnv,
stdio: 'inherit',
})
: spawn(npmCommand, ['run', 'dev'], {
cwd: serverRoot,
env: mergedEnv,
shell: process.platform === 'win32',
stdio: 'inherit',
});
const rustApiProcess = process.platform === 'win32'
? spawn(
'powershell',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
path.join(serverRsRoot, 'scripts', 'dev.ps1'),
'-ApiHost',
mergedEnv.GENARRATIVE_API_HOST,
'-Port',
mergedEnv.GENARRATIVE_API_PORT,
],
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
},
)
: spawn(
'bash',
[
path.join(serverRsRoot, 'scripts', 'dev.sh'),
],
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
},
);
const viteProcess = spawn(
runtimeNodePath,
[viteCliPath, '--port=3000', `--host=${mergedEnv.VITE_DEV_HOST}`],
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
},
);
registerChild('node server', serverProcess, () => viteProcess);
registerChild('rust api-server', rustApiProcess, () => viteProcess);
registerChild('vite dev server', viteProcess, () => serverProcess);
process.on('SIGINT', () => {
console.log('[dev:node] received SIGINT, shutting down...');
requestShutdown(0);
});
process.on('SIGTERM', () => {
console.log('[dev:node] received SIGTERM, shutting down...');
requestShutdown(0);
});