@xtr-dev/zod-rpc
Version:
Simple, type-safe RPC library with Zod validation and automatic TypeScript inference
395 lines (339 loc) • 14.4 kB
JavaScript
#!/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();