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:

TriggerWhenUse Case
watchDuring spfn dev file watchingDevelopment-time updates
manualWhen running spfn codegen commandOn-demand generation
buildDuring spfn buildBuild-time generation
startOn server startupRuntime 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:

typescript
async 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:

typescript
const 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

typescript
async 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

typescript
async 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

typescript
async 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:

typescript
if (options.trigger?.changedFile) {
  // Only process changed file
  await processFile(options.trigger.changedFile.path);
  return;
}

// Fallback: full regeneration
await processAllFiles();

Next Steps

Tip: Start simple! Create a basic generator first, then add features like incremental updates and configuration options as needed.