UNPKG

@xtr-dev/zod-rpc

Version:

Simple, type-safe RPC library with Zod validation and automatic TypeScript inference

395 lines (339 loc) 14.4 kB
#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { program } = require('commander'); const { execSync } = require('child_process'); program .name('zod-rpc-schemas') .description('Generate JSON schemas from Zod RPC service definitions using Zod v4') .version('1.0.0') .requiredOption('-s, --source <path>', 'Source file containing service definitions') .option('-d, --dest <path>', 'Destination file for generated schemas', './schemas.json') .option('--pretty', 'Pretty print JSON output') .parse(); const options = program.opts(); function detectTypeScriptEnvironment() { let hasTsx = false; let hasTsc = false; // Check for TypeScript compiler first (preferred for better compatibility) try { execSync('npx tsc --version', { stdio: 'pipe' }); hasTsc = true; } catch { // tsc not available } // Check for tsx try { execSync('npx tsx --version', { stdio: 'pipe' }); hasTsx = true; } catch { // tsx not available } return { hasTsx, hasTsc }; } async function generateSchemas() { try { const sourceFile = path.resolve(options.source); if (!fs.existsSync(sourceFile)) { console.error(`❌ Source file not found: ${sourceFile}`); process.exit(1); } console.log(`📋 Generating JSON schemas from: ${sourceFile}`); console.log(`📁 Output file: ${path.resolve(options.dest)}`); // Check if source is TypeScript and detect environment capabilities let actualSourceFile = sourceFile; const isTypeScript = sourceFile.endsWith('.ts'); const tsEnv = detectTypeScriptEnvironment(); if (isTypeScript && !tsEnv.hasTsx && !tsEnv.hasTsc) { console.error(`❌ TypeScript file detected but no TypeScript environment available.`); console.error(`💡 Please install tsx (npm install -g tsx) or typescript (npm install -g typescript) to process .ts files.`); console.error(`💡 Alternatively, compile your TypeScript file to JavaScript first and use the .js file instead.`); process.exit(1); } if (isTypeScript) { if (tsEnv.hasTsc) { console.log('🔧 Compiling TypeScript to avoid tsx compatibility issues...'); // Compile TypeScript to a temporary JavaScript file const tempDir = path.join(path.dirname(sourceFile), '.temp-schemas'); const tempFile = path.join(tempDir, 'compiled-source.mjs'); try { // Create temp directory if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } // Use tsc to compile the TypeScript file with better module resolution const sourceDir = path.dirname(sourceFile); execSync(`npx tsc "${sourceFile}" --target es2020 --module esnext --outDir "${tempDir}" --allowSyntheticDefaultImports --esModuleInterop --moduleResolution node --skipLibCheck`, { stdio: 'pipe', cwd: sourceDir // Run from source directory for better module resolution }); // Find the compiled file const compiledName = path.basename(sourceFile, '.ts') + '.js'; const compiledFile = path.join(tempDir, compiledName); if (fs.existsSync(compiledFile)) { // Rename to .mjs for proper ES module handling fs.renameSync(compiledFile, tempFile); actualSourceFile = tempFile; console.log(`✅ Compiled to: ${tempFile}`); } else { throw new Error('Compiled file not found'); } } catch (error) { if (tsEnv.hasTsx) { console.warn(`⚠️ TypeScript compilation failed: ${error.message}, falling back to tsx processing`); } else { console.error(`❌ TypeScript compilation failed: ${error.message}`); console.error(`💡 Try installing tsx (npm install -g tsx) for fallback TypeScript processing`); process.exit(1); } } } else if (tsEnv.hasTsx) { console.log('📝 Using tsx for TypeScript processing (descriptions may be lost due to tsx limitations)'); console.log('💡 For better description support, install typescript compiler: npm install -g typescript'); } } // Create a runner script that generates JSON schemas const runnerScript = ` import path from 'path'; import fs from 'fs'; async function run() { try { // Load the source file (now guaranteed to be JavaScript for proper Zod instance handling) const sourceModule = await import('${actualSourceFile}'); // Don't import Zod separately - we'll use the schema's own conversion methods let toJSONSchemaFunction = null; // Find the toJSONSchema function from any schema in the source module for (const [key, value] of Object.entries(sourceModule)) { if (value && typeof value === 'object' && value.methods) { for (const [methodName, methodDef] of Object.entries(value.methods)) { if (methodDef.input) { // Try different ways to find the toJSONSchema function const schema = methodDef.input; // Method 1: Direct on constructor if (schema.constructor && schema.constructor.toJSONSchema) { toJSONSchemaFunction = schema.constructor.toJSONSchema.bind(schema.constructor); break; } // Method 2: Walk up prototype chain let proto = schema.constructor; while (proto && !toJSONSchemaFunction) { if (proto.toJSONSchema) { toJSONSchemaFunction = proto.toJSONSchema.bind(proto); break; } proto = Object.getPrototypeOf(proto); } if (toJSONSchemaFunction) break; } } if (toJSONSchemaFunction) break; } } // If we still haven't found it, import zod as fallback if (!toJSONSchemaFunction) { const zodModule = await import('zod'); toJSONSchemaFunction = zodModule.z.toJSONSchema.bind(zodModule.z); } // Extract services and generate JSON schemas const services = []; const schemas = { $schema: "http://json-schema.org/draft-07/schema#", title: "RPC API Schemas", description: "JSON schemas for RPC service definitions generated from Zod schemas", type: "object", properties: { services: { type: "object", properties: {} } }, definitions: {} }; for (const [key, value] of Object.entries(sourceModule)) { if (value && typeof value === 'object' && value.id && value.methods) { // Use console.error for debug messages to avoid interfering with JSON output console.error(\`📦 Found service: \${value.id}\`); services.push({ key, service: value }); // Generate JSON schema for this service const serviceSchema = generateServiceSchema(key, value, toJSONSchemaFunction); schemas.properties.services.properties[value.id] = serviceSchema.service; // Add method schemas to definitions Object.assign(schemas.definitions, serviceSchema.definitions); } } if (services.length === 0) { console.error('❌ No service definitions found in source file'); process.exit(1); } // Write the generated JSON schema file const outputFile = path.resolve('${options.dest}'); const jsonOutput = ${options.pretty ? 'JSON.stringify(schemas, null, 2)' : 'JSON.stringify(schemas)'}; fs.writeFileSync(outputFile, jsonOutput); console.error(\`✅ Generated schemas: \${path.relative(process.cwd(), outputFile)}\`); return { services, outputFile, schemas }; } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } } function convertZodToJsonSchema(schema, schemaName, toJSONSchemaFunction) { try { // Use the provided toJSONSchema function which should be from the same Zod instance const jsonSchema = toJSONSchemaFunction(schema); // Add title if not present if (!jsonSchema.title && schemaName) { jsonSchema.title = schemaName; } return jsonSchema; } catch (error) { console.warn(\`⚠️ Warning: Could not convert schema \${schemaName}: \${error.message}\`); return { type: 'object', title: schemaName, description: \`Error converting schema: \${error.message}\` }; } } function generateServiceSchema(exportName, service, toJSONSchemaFunction) { const serviceName = service.id.charAt(0).toUpperCase() + service.id.slice(1); const definitions = {}; const methods = {}; // Generate schema for each method for (const [methodName, methodDef] of Object.entries(service.methods)) { const methodNameCap = methodName.charAt(0).toUpperCase() + methodName.slice(1); // Convert input schema const inputSchemaName = \`\${serviceName}\${methodNameCap}Input\`; const inputSchema = convertZodToJsonSchema(methodDef.input, inputSchemaName, toJSONSchemaFunction); definitions[inputSchemaName] = inputSchema; // Convert output schema const outputSchemaName = \`\${serviceName}\${methodNameCap}Output\`; const outputSchema = convertZodToJsonSchema(methodDef.output, outputSchemaName, toJSONSchemaFunction); definitions[outputSchemaName] = outputSchema; // Method definition methods[methodName] = { type: "object", title: \`\${serviceName} \${methodName} method\`, description: \`Method signature for \${service.id}.\${methodName}\`, properties: { input: { "$ref": \`#/definitions/\${inputSchemaName}\` }, output: { "$ref": \`#/definitions/\${outputSchemaName}\` } }, required: ["input", "output"] }; } const serviceSchema = { type: "object", title: \`\${serviceName} Service\`, description: \`Service definition for \${service.id} with \${Object.keys(service.methods).length} method\${Object.keys(service.methods).length === 1 ? '' : 's'}\`, properties: { id: { type: "string", const: service.id }, methods: { type: "object", properties: methods, required: Object.keys(methods) } }, required: ["id", "methods"] }; return { service: serviceSchema, definitions }; } const result = await run(); console.log(JSON.stringify(result, null, 2)); `; // Write and execute runner script with tsx const runnerPath = path.join(__dirname, 'schemas-runner.mjs'); fs.writeFileSync(runnerPath, runnerScript); try { // Execute the runner script to generate JSON schemas console.log('🔧 Processing service definitions...'); // Determine the right command based on the source file type and environment let command; if (actualSourceFile.endsWith('.mjs') || actualSourceFile.endsWith('.js')) { command = `node "${runnerPath}"`; } else if (tsEnv.hasTsx) { command = `npx tsx "${runnerPath}"`; } else { console.error('❌ Cannot execute TypeScript runner script without tsx or compiled JavaScript'); process.exit(1); } const result = execSync(command, { cwd: process.cwd(), stdio: 'pipe', encoding: 'utf8' }); // Clean up runner script immediately after execution if (fs.existsSync(runnerPath)) { fs.unlinkSync(runnerPath); } // Clean up temporary TypeScript compilation files if (isTypeScript && tsEnv.hasTsc && actualSourceFile !== sourceFile) { const tempDir = path.dirname(actualSourceFile); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } } // Parse the result to verify success const lines = result.split('\n'); const jsonLine = lines.find(line => line.trim().startsWith('{')); if (jsonLine) { try { const parsed = JSON.parse(jsonLine.trim()); if (parsed.outputFile && fs.existsSync(parsed.outputFile)) { console.log(`📋 JSON schemas successfully generated!`); console.log(`📊 Generated schemas for ${parsed.services.length} service${parsed.services.length === 1 ? '' : 's'}`); } else { throw new Error('Generated schemas file not found'); } } catch (parseError) { // JSON parsing failed, but check if the output file exists anyway const outputFile = path.resolve(options.dest); if (fs.existsSync(outputFile)) { console.log(`📋 JSON schemas successfully generated!`); console.log(`📁 Output: ${path.relative(process.cwd(), outputFile)}`); } else { throw new Error(`JSON parsing failed and output file not found: ${parseError.message}`); } } } else { // No JSON output found, but check if file was created const outputFile = path.resolve(options.dest); if (fs.existsSync(outputFile)) { console.log(`📋 JSON schemas successfully generated!`); console.log(`📁 Output: ${path.relative(process.cwd(), outputFile)}`); } else { throw new Error('No JSON output found and no output file created'); } } } catch (error) { // Clean up runner script on error too if (fs.existsSync(runnerPath)) { fs.unlinkSync(runnerPath); } // Clean up temporary TypeScript compilation files on error too if (isTypeScript && tsEnv.hasTsc && actualSourceFile !== sourceFile) { const tempDir = path.dirname(actualSourceFile); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } } throw error; } } catch (error) { console.error('❌ Error generating schemas:', error.message); if (error.code === 'MODULE_NOT_FOUND') { console.log('💡 Make sure to build the project first or install dependencies'); } process.exit(1); } } generateSchemas();