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