import {spawn} from 'node:child_process'; import {existsSync} from 'node:fs'; import {cp, mkdir, readdir, rm, stat} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); const MODULE_PATH = path.join(REPO_ROOT, 'server-rs', 'crates', 'spacetime-module'); const TARGETS = [ { name: 'Rust', lang: 'rust', tempName: 'rs', outDir: path.join( REPO_ROOT, 'server-rs', 'crates', 'spacetime-client', 'src', 'module_bindings', ), }, ]; const args = new Set(process.argv.slice(2)); const KNOWN_ARGS = new Set(['--rust-only']); for (const arg of args) { if (!KNOWN_ARGS.has(arg)) { console.error(`[spacetime:generate] 未知参数: ${arg}`); process.exit(1); } } if (!existsSync(path.join(MODULE_PATH, 'Cargo.toml'))) { console.error(`[spacetime:generate] 未找到模块: ${MODULE_PATH}`); process.exit(1); } const tempRoot = resolveTempRoot(); assertSafeTempRoot(tempRoot); const selectedTargets = TARGETS.filter((target) => shouldRunTarget(target.lang)); if (selectedTargets.length === 0) { console.error('[spacetime:generate] 没有需要生成的目标。'); process.exit(1); } await mkdir(tempRoot, {recursive: true}); for (const target of selectedTargets) { const tempOutDir = path.join(tempRoot, target.tempName); await recreateTempDir(tempOutDir); console.log(`[spacetime:generate] 生成 ${target.name} bindings 到短路径: ${tempOutDir}`); await generateBindings(target, tempOutDir); const fileCount = await countFiles(tempOutDir); if (fileCount === 0) { throw new Error(`${target.name} bindings 未生成任何文件。`); } console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`); await replaceGeneratedDir(tempOutDir, target.outDir); } await rm(tempRoot, {recursive: true, force: true}); console.log('[spacetime:generate] bindings 生成完成。'); function shouldRunTarget(lang) { if (args.has('--rust-only')) { return lang === 'rust'; } return true; } function resolveTempRoot() { if (process.env.GENARRATIVE_BINDGEN_TEMP_ROOT) { return path.resolve(process.env.GENARRATIVE_BINDGEN_TEMP_ROOT); } // Windows 下 SpacetimeDB CLI 2.1.0 会把所有生成文件路径一次性传给 formatter; // Rust bindings 文件数较多,输出到仓库深目录时容易触发 CreateProcess 路径总长限制。 if (process.platform === 'win32') { return path.join(path.parse(REPO_ROOT).root, '.genarrative-bindgen'); } return path.join(REPO_ROOT, 'tmp', 'spacetime-bindgen'); } async function recreateTempDir(dir) { assertInside(dir, tempRoot, '临时生成目录'); await rm(dir, {recursive: true, force: true}); await mkdir(dir, {recursive: true}); } async function replaceGeneratedDir(fromDir, toDir) { assertInside(toDir, REPO_ROOT, '仓库生成目录'); await rm(toDir, {recursive: true, force: true}); await mkdir(toDir, {recursive: true}); const entries = await readdir(fromDir, {withFileTypes: true}); for (const entry of entries) { await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), { recursive: true, force: true, }); } } function assertInside(candidate, parent, label) { const relative = path.relative(path.resolve(parent), path.resolve(candidate)); if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`${label} 不在预期目录内: ${candidate}`); } } function assertSafeTempRoot(dir) { const resolved = path.resolve(dir); const parsed = path.parse(resolved); const basename = path.basename(resolved).toLowerCase(); if (resolved === path.resolve(REPO_ROOT) || resolved === parsed.root) { throw new Error(`临时根目录不允许指向仓库或磁盘根目录: ${resolved}`); } if (!basename.includes('bindgen')) { throw new Error(`临时根目录必须是明确的 bindings 生成目录: ${resolved}`); } } function buildGenerateArgs(target, outDir) { const generateArgs = [ 'generate', '--no-config', '--lang', target.lang, '--out-dir', outDir, '--module-path', MODULE_PATH, '--include-private', '--yes', ]; return generateArgs; } async function generateBindings(target, outDir) { const result = await run('spacetime', buildGenerateArgs(target, outDir), { allowGeneratedFormatFailure: target.lang === 'rust', }); if (result.generatedFormatFailed) { // Windows 下 SpacetimeDB CLI 2.1.0 会把所有 Rust 文件一次性传给 formatter; // 这里只接管“文件已生成但 CLI 格式化失败”的尾段,并仍然只同步生成目录。 console.warn( `[spacetime:generate] ${target.name} bindings 已生成,但 SpacetimeDB CLI 自带格式化失败;改用短路径分批 rustfmt。`, ); await formatRustBindings(outDir); } } async function formatRustBindings(outDir) { const rustFiles = await collectRustFiles(outDir); if (rustFiles.length === 0) { throw new Error(`Rust bindings 未生成任何 .rs 文件,无法格式化: ${outDir}`); } for (const chunk of chunkCommandArgs(rustFiles)) { await run('rustfmt', ['--edition', '2024', ...chunk]); } } async function collectRustFiles(dir) { const files = []; const entries = await readdir(dir, {withFileTypes: true}); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await collectRustFiles(entryPath))); continue; } if (entry.isFile() && entry.name.endsWith('.rs')) { files.push(entryPath); } } return files; } function chunkCommandArgs(argsToChunk) { // Windows CreateProcess 受命令行长度限制;分批能避免 bindings 文件变多后再次失败。 const maxCommandLineChars = process.platform === 'win32' ? 20_000 : 100_000; const chunks = []; let current = []; let currentLength = 0; for (const arg of argsToChunk) { const argLength = arg.length + 3; if (current.length > 0 && currentLength + argLength > maxCommandLineChars) { chunks.push(current); current = []; currentLength = 0; } current.push(arg); currentLength += argLength; } if (current.length > 0) { chunks.push(current); } return chunks; } function run(command, commandArgs, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, commandArgs, { cwd: REPO_ROOT, env: process.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'], }); let output = ''; child.stdout.on('data', (chunk) => { const text = chunk.toString(); output += text; process.stdout.write(text); }); child.stderr.on('data', (chunk) => { const text = chunk.toString(); output += text; process.stderr.write(text); }); child.on('error', reject); child.on('exit', (code, signal) => { if (signal) { reject(new Error(`${command} 被信号中断: ${signal}`)); return; } const generatedFormatFailed = output.includes('Could not format generated files'); if (generatedFormatFailed && options.allowGeneratedFormatFailure) { console.warn(`[spacetime:generate] ${command} generated files but formatting failed; continuing with validation.`); resolve({generatedFormatFailed}); return; } if (generatedFormatFailed) { reject(new Error(`${command} generated files but formatting failed.`)); return; } if (code === 0) { resolve({generatedFormatFailed: false}); return; } reject(new Error(`${command} 退出码: ${code ?? 'unknown'}`)); }); }); } async function countFiles(dir) { let count = 0; const entries = await readdir(dir, {withFileTypes: true}); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { count += await countFiles(entryPath); continue; } if (entry.isFile() || (await stat(entryPath)).isFile()) { count += 1; } } return count; }