Custom Generators
Superfunction's code generation system is extensible, allowing you to create custom generators for any code generation needs in your project.
What are Generators?
Generators are automated tools that scan your codebase and generate code based on patterns or conventions. Common use cases include:
- Generating route metadata for RPC clients (built-in)
- Creating navigation menus from route configurations
- Building database migrations from schema definitions
- Generating type definitions from external APIs
- Creating form components from data models
Basic Generator Structure
A generator is a simple object that implements the Generator interface:
typescript// src/generators/my-generator.ts import type { Generator, GeneratorOptions } from '@spfn/core/codegen'; export function createMyGenerator(): Generator { return { // Unique name for this generator name: 'my-generator', // File patterns to watch (glob syntax) watchPatterns: ['src/features/**/*.config.ts'], // When to run: 'watch' | 'manual' | 'build' | 'start' // Default: ['watch', 'manual', 'build'] runOn: ['watch', 'build'], // Main generation function async generate(options: GeneratorOptions): Promise<void> { const { cwd, debug } = options; // 1. Scan source files // 2. Process data // 3. Generate output files if (debug) { console.log('✅ Generated successfully'); } } }; }
Understanding runOn
The runOn option controls when your generator executes:
| Trigger | When | Use Case |
|---|---|---|
watch | During spfn dev file watching | Development-time updates |
manual | When running spfn codegen command | On-demand generation |
build | During spfn build | Build-time generation |
start | On server startup | Runtime initialization |
Examples:
typescript// Run during development and build (skip manual CLI and server start) runOn: ['watch', 'build'] // Run only during build process runOn: ['build'] // Run only on server start (e.g., runtime config) runOn: ['start'] // Run on everything (default) runOn: ['watch', 'manual', 'build', 'start']
Example: Admin Navigation Generator
Let's build a real-world generator that creates navigation menus from route configuration files.
Step 1: Define the Structure
typescript// src/app/admin/users/nav.config.tsx export const navConfig = { title: 'Users', icon: 'Users', path: '/admin/users', order: 10 };
Step 2: Create the Generator
typescript// src/generators/admin-nav-generator.ts import type { Generator, GeneratorOptions } from '@spfn/core/codegen'; import { glob } from 'glob'; import { readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; interface NavItem { title: string; icon: string; path: string; order: number; } export function createAdminNavGenerator(): Generator { return { name: 'admin-nav', watchPatterns: ['src/app/admin/**/nav.config.tsx'], runOn: ['watch', 'build'], async generate(options: GeneratorOptions): Promise<void> { const { cwd, debug } = options; if (debug) { console.log('🔄 Generating admin navigation...'); } // 1. Find all nav.config.tsx files const configFiles = await glob( 'src/app/admin/**/nav.config.tsx', { cwd, absolute: true } ); // 2. Extract nav items const navItems: NavItem[] = []; for (const file of configFiles) { // Dynamic import for TypeScript files const module = await import(file); if (module.navConfig) { navItems.push(module.navConfig); } } // 3. Sort by order navItems.sort((a, b) => a.order - b.order); // 4. Generate TypeScript file const outputPath = join(cwd, 'src/lib/admin/nav-data.generated.ts'); const code = generateNavCode(navItems); writeFileSync(outputPath, code, 'utf-8'); if (debug) { console.log(`✅ Generated ${navItems.length} navigation items`); } } }; } function generateNavCode(items: NavItem[]): string { return `/** * Auto-generated admin navigation * Do not edit this file manually! */ export const adminNavItems = ${JSON.stringify(items, null, 2)} as const; export type AdminNavItem = typeof adminNavItems[number]; `; }
Step 3: Register the Generator
json// .spfnrc.json { "codegen": { "generators": [ { "path": "./src/generators/admin-nav-generator.ts" } ] } }
Step 4: Use Generated Code
typescript// src/app/admin/layout.tsx import { adminNavItems } from '@/lib/admin/nav-data.generated'; export default function AdminLayout({ children }) { return ( <div> <nav> {adminNavItems.map((item) => ( <a key={item.path} href={item.path}> {item.title} </a> ))} </nav> {children} </div> ); }
Implementing Incremental Updates
For better performance, generators can implement smart regeneration by checking the trigger option:
typescriptasync generate(options: GeneratorOptions): Promise<void> { const { cwd, trigger } = options; // Check if triggered by file change if (trigger?.changedFile) { const { path, event } = trigger.changedFile; // If you can do incremental update if (event === 'change' && canUpdateIncrementally(path)) { await updateSingleFile(path); return; // Skip full regeneration } // If file was added or deleted, need full regeneration if (event === 'add' || event === 'unlink') { // Fall through to full regeneration } } // Full regeneration await fullRegenerate(cwd); }
When to Use Incremental Updates
Good candidates:
- Large codebases with many source files
- Independent file processing (no cross-file dependencies)
- Expensive computation per file
Not recommended:
- Small projects (overhead > benefit)
- Files with complex interdependencies
- When full regen is already fast (< 100ms)
Generator Configuration
Generators can accept configuration options:
typescript// src/generators/feature-generator.ts interface FeatureGeneratorConfig { outputDir?: string; includeTests?: boolean; templatePath?: string; } export function createFeatureGenerator( config: FeatureGeneratorConfig = {} ): Generator { const { outputDir = 'src/generated', includeTests = true, templatePath } = config; return { name: 'feature-generator', watchPatterns: ['src/features/**/*.feature.ts'], async generate(options: GeneratorOptions): Promise<void> { // Use config options const output = join(options.cwd, outputDir); // ... } }; }
json// .spfnrc.json { "codegen": { "generators": [ { "path": "./src/generators/feature-generator.ts", "options": { "outputDir": "src/lib/features", "includeTests": true } } ] } }
Testing Your Generator
Create tests to ensure your generator works correctly:
typescript// src/generators/__tests__/admin-nav-generator.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs'; import { join } from 'path'; import { createAdminNavGenerator } from '../admin-nav-generator'; const TEST_DIR = join(process.cwd(), '.test-tmp'); describe('Admin Nav Generator', () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); }); afterEach(() => { rmSync(TEST_DIR, { recursive: true, force: true }); }); it('should generate navigation from config files', async () => { // Setup: Create test config files const configDir = join(TEST_DIR, 'src/app/admin/users'); mkdirSync(configDir, { recursive: true }); writeFileSync( join(configDir, 'nav.config.tsx'), `export const navConfig = { title: 'Users', icon: 'Users', path: '/admin/users', order: 10 };` ); // Run generator const generator = createAdminNavGenerator(); await generator.generate({ cwd: TEST_DIR, debug: true }); // Verify output const outputPath = join(TEST_DIR, 'src/lib/admin/nav-data.generated.ts'); const output = readFileSync(outputPath, 'utf-8'); expect(output).toContain('Users'); expect(output).toContain('/admin/users'); }); });
Best Practices
1. Always Generate with Headers
Add a header comment to generated files:
typescriptconst code = `/** * Auto-generated by ${generator.name} * * DO NOT EDIT THIS FILE MANUALLY! * Changes will be overwritten on next generation. * * Generated at: ${new Date().toISOString()} * Source: ${sourceFiles.join(', ')} */ ${generatedCode} `;
2. Use Consistent File Naming
Follow conventions for generated files:
typescript// Good 'src/lib/api.generated.ts' 'src/types/models.generated.ts' 'src/config/routes.generated.ts' // Avoid 'src/lib/api.ts' // Looks like manual code 'src/lib/api_gen.ts' // Inconsistent naming
3. Validate Input Before Generation
typescriptasync generate(options: GeneratorOptions): Promise<void> { const sourceFiles = await findSourceFiles(options.cwd); // Validate before generating if (sourceFiles.length === 0) { if (options.debug) { console.warn('No source files found, skipping generation'); } return; } // Validate each file for (const file of sourceFiles) { const isValid = await validateFile(file); if (!isValid) { throw new Error(`Invalid source file: ${file}`); } } // Now generate await doGeneration(sourceFiles); }
4. Handle Errors Gracefully
typescriptasync generate(options: GeneratorOptions): Promise<void> { try { await doGeneration(options); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); console.error(`[${this.name}] Generation failed:`, err.message); if (options.debug) { console.error('Stack trace:', err.stack); } throw err; // Re-throw for orchestrator to handle } }
5. Provide Debug Information
typescriptasync generate(options: GeneratorOptions): Promise<void> { const startTime = Date.now(); if (options.debug) { console.log(`[${this.name}] Starting generation...`); } const result = await doGeneration(options); if (options.debug) { const duration = Date.now() - startTime; console.log(`[${this.name}] Generated ${result.filesGenerated} files in ${duration}ms`); } }
Advanced Example: Database Schema Generator
A more complex generator that creates TypeScript types from database schemas:
typescript// src/generators/db-schema-generator.ts import type { Generator, GeneratorOptions } from '@spfn/core/codegen'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Client } from 'pg'; export function createDbSchemaGenerator(): Generator { return { name: 'db-schema', watchPatterns: ['src/db/schema/**/*.ts'], runOn: ['manual', 'build'], // Not watch (DB changes are less frequent) async generate(options: GeneratorOptions): Promise<void> { const { cwd, debug } = options; // Connect to database const client = new Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); try { // Query schema information const tables = await client.query(` SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position `); // Generate TypeScript types const types = generateTypesFromSchema(tables.rows); // Write to file const outputPath = join(cwd, 'src/types/db.generated.ts'); writeFileSync(outputPath, types); if (debug) { console.log(`✅ Generated types for ${tables.rowCount} columns`); } } finally { await client.end(); } } }; }
Troubleshooting
Generator Not Running
Check runOn configuration:
typescript// If generator isn't running during dev runOn: ['watch', 'build'] // Include 'watch' // If not running on spfn codegen command runOn: ['manual', 'watch'] // Include 'manual'
Verify registration:
json// .spfnrc.json - Check path is correct { "codegen": { "generators": [ { "path": "./src/generators/my-generator.ts" // Must be relative to project root } ] } }
Files Not Being Watched
Check glob patterns:
typescript// Too specific watchPatterns: ['src/app/admin/users/nav.config.tsx'] // Only one file // Better watchPatterns: ['src/app/admin/**/nav.config.tsx'] // All nav.config.tsx files
Performance Issues
Use incremental updates:
typescriptif (options.trigger?.changedFile) { // Only process changed file await processFile(options.trigger.changedFile.path); return; } // Fallback: full regeneration await processAllFiles();
Next Steps
- Review the built-in contract generator for a complete example
- Explore testing patterns for generators
- Learn about error handling in generators
Tip: Start simple! Create a basic generator first, then add features like incremental updates and configuration options as needed.