UNPKG

@xtr-dev/zod-rpc

Version:

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

337 lines (276 loc) 10.6 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-types') .description('Generate TypeScript types from Zod RPC service definitions') .version('1.0.0') .requiredOption('-s, --source <path>', 'Source file containing service definitions') .option('-d, --dest <path>', 'Destination file for generated types', './types.ts') .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 generateTypes() { try { const sourceFile = path.resolve(options.source); if (!fs.existsSync(sourceFile)) { console.error(`❌ Source file not found: ${sourceFile}`); process.exit(1); } console.log(`📝 Generating TypeScript types from: ${sourceFile}`); console.log(`📁 Output file: ${path.resolve(options.dest)}`); // Check TypeScript environment 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); } // Create a runner script that generates TypeScript interfaces from JSON schemas const runnerScript = ` import path from 'path'; import fs from 'fs'; import { z } from 'zod'; async function run() { try { // Load the source file (works with both .js and .ts) const sourceModule = await import('${sourceFile}'); // Extract services and generate JSON schemas first const services = []; const schemas = { definitions: {} }; for (const [key, value] of Object.entries(sourceModule)) { if (value && typeof value === 'object' && value.id && value.methods) { console.log(\`📦 Found service: \${value.id}\`); services.push({ key, service: value }); // Generate JSON schemas for this service generateServiceSchemas(key, value, schemas); } } if (services.length === 0) { console.error('❌ No service definitions found in source file'); process.exit(1); } // Now generate TypeScript interfaces from the JSON schemas let typeDefinitions = \`/** * Auto-generated TypeScript types for Zod RPC services * Generated from: ${path.basename(sourceFile)} * * This file contains TypeScript interfaces generated from JSON schemas. */ \`; // Generate interfaces from schemas typeDefinitions += generateTypesFromSchemas(services, schemas); // Write the generated TypeScript file const outputFile = path.resolve('${options.dest}'); fs.writeFileSync(outputFile, typeDefinitions); console.log(\`✅ Generated types: \${path.relative(process.cwd(), outputFile)}\`); return { services, outputFile }; } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } } function convertZodToJsonSchema(schema, schemaName) { try { const jsonSchema = z.toJSONSchema(schema); 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 generateServiceSchemas(exportName, service, schemas) { const serviceName = service.id.charAt(0).toUpperCase() + service.id.slice(1); // 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); schemas.definitions[inputSchemaName] = inputSchema; // Convert output schema const outputSchemaName = \`\${serviceName}\${methodNameCap}Output\`; const outputSchema = convertZodToJsonSchema(methodDef.output, outputSchemaName); schemas.definitions[outputSchemaName] = outputSchema; } } function jsonSchemaToTypeScript(schema, interfaceName) { if (!schema || schema.type !== 'object') { return \`export type \${interfaceName} = any;\`; } let tsInterface = \`export interface \${interfaceName} {\`; if (schema.properties) { for (const [propName, propSchema] of Object.entries(schema.properties)) { const isOptional = !schema.required?.includes(propName) || propSchema.default !== undefined; const optionalMark = isOptional ? '?' : ''; let propType = 'any'; if (propSchema.type === 'string') { propType = 'string'; } else if (propSchema.type === 'number') { propType = 'number'; } else if (propSchema.type === 'boolean') { propType = 'boolean'; } else if (propSchema.type === 'array') { if (propSchema.items?.type === 'object') { propType = 'object[]'; } else if (propSchema.items?.type) { propType = \`\${propSchema.items.type}[]\`; } else { propType = 'any[]'; } } else if (propSchema.type === 'object') { propType = 'object'; } const description = propSchema.description ? \` /** \${propSchema.description} */\` : ''; tsInterface += \`\${description} \${propName}\${optionalMark}: \${propType};\`; } } tsInterface += \` }\`; return tsInterface; } function generateTypesFromSchemas(services, schemas) { let typeDefinitions = ''; // Generate individual type definitions from schemas for (const [schemaName, schema] of Object.entries(schemas.definitions)) { typeDefinitions += jsonSchemaToTypeScript(schema, schemaName) + \` \`; } // Generate service namespaces for (const { service } of services) { const serviceName = service.id.charAt(0).toUpperCase() + service.id.slice(1); typeDefinitions += \`/** * \${serviceName} Service * * \${Object.keys(service.methods).length} method\${Object.keys(service.methods).length === 1 ? '' : 's'} available: \${Object.keys(service.methods).join(', ')} */ export namespace \${serviceName}Service { \`; // Reference the generated types for (const [methodName] of Object.entries(service.methods)) { const methodNameCap = methodName.charAt(0).toUpperCase() + methodName.slice(1); typeDefinitions += \` export type \${methodNameCap}Input = \${serviceName}\${methodNameCap}Input; export type \${methodNameCap}Output = \${serviceName}\${methodNameCap}Output; \`; } // Generate the service interface typeDefinitions += \` /** * Service interface with all available methods */ export interface Service { \`; for (const [methodName] of Object.entries(service.methods)) { const methodNameCap = methodName.charAt(0).toUpperCase() + methodName.slice(1); typeDefinitions += \` /** * \${methodName} method */ \${methodName}(input: \${methodNameCap}Input): Promise<\${methodNameCap}Output>; \`; } typeDefinitions += \` } } \`; } // Generate main API interface typeDefinitions += \`/** * Main API interface that combines all services * This represents the complete API surface that clients can interact with */ export interface API { \`; for (const { service } of services) { const serviceName = service.id.charAt(0).toUpperCase() + service.id.slice(1); typeDefinitions += \` /** * \${serviceName} service methods */ \${service.id}: \${serviceName}Service.Service; \`; } typeDefinitions += \`} \`; return typeDefinitions; } const result = await run(); console.log(JSON.stringify(result, null, 2)); `; // Write and execute runner script with tsx const runnerPath = path.join(__dirname, 'types-runner.mjs'); fs.writeFileSync(runnerPath, runnerScript); try { // Execute the runner script to generate TypeScript interfaces console.log('🔧 Processing service definitions...'); // Use tsx only if available and needed for TypeScript files const command = isTypeScript && tsEnv.hasTsx ? `npx tsx "${runnerPath}"` : `node "${runnerPath}"`; if (isTypeScript && !tsEnv.hasTsx) { console.log('💡 TypeScript file detected but tsx not available. Using node (may have import issues).'); console.log('💡 Install tsx for better TypeScript support: npm install -g tsx'); } 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); } // Parse the result to verify success const jsonStartIndex = result.indexOf('{'); if (jsonStartIndex !== -1) { const jsonString = result.substring(jsonStartIndex); const parsed = JSON.parse(jsonString); if (parsed.outputFile && fs.existsSync(parsed.outputFile)) { console.log(`📄 TypeScript types successfully generated!`); } else { throw new Error('Generated types file not found'); } } } catch (error) { // Clean up runner script on error too if (fs.existsSync(runnerPath)) { fs.unlinkSync(runnerPath); } throw error; } } catch (error) { console.error('❌ Error generating types:', error.message); if (error.code === 'MODULE_NOT_FOUND') { console.log('💡 Make sure to build the project first or install dependencies'); } process.exit(1); } } generateTypes();