Creating Custom Plugins

While Form Engine comes with several built-in plugins, you can create custom plugins for specific behaviors your forms need. This guide shows you how to build and register your own action plugins.

Plugin Architecture

A plugin is a factory function that returns an action registration object:

import { z } from 'zod';
import type { ActionExecutionContext, ActionExecutionResult } from '@schema-engine/actions';
 
// 1. Define the action schema
const MyActionSchema = z.object({
	id: z.string(),
	type: z.literal('my_action'),
	customProperty: z.string(),
	enabled: z.boolean().default(true),
	triggers: z.array(z.string()).optional(),
});
 
// 2. Create the plugin factory
export function myPlugin(config: MyPluginConfig = {}) {
	return {
		type: 'my_action' as const,
		schema: MyActionSchema,
		defaultTriggers: ['form_submit'] as const,
		handler: async (action, context) => {
			// Your custom logic here
			return { success: true, message: 'Action completed' };
		},
	};
}
 
// 3. Export types for TypeScript
export type MyAction = z.infer<typeof MyActionSchema>;
export interface MyPluginConfig {
	onComplete?: (result: any, context: ActionExecutionContext) => void;
	validateData?: (data: any) => boolean;
}

Step-by-Step: Creating a Logger Plugin

Let's create a plugin that logs form interactions to an external service:

Step 1: Define the Schema

import { z } from 'zod';
 
export const LoggerActionSchema = z.object({
	id: z.string().min(1, 'Action ID is required'),
	type: z.literal('logger'),
	enabled: z.boolean().default(true),
	triggers: z.array(z.string()).optional(),
 
	// Plugin-specific properties
	logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
	message: z.string().optional(),
	includeFormData: z.boolean().default(false),
	includeUserAgent: z.boolean().default(false),
	tags: z.array(z.string()).default([]),
});
 
export type LoggerAction = z.infer<typeof LoggerActionSchema>;

Step 2: Define Configuration Interface

import type { ActionExecutionContext } from '@schema-engine/actions';
 
export interface LoggerPluginConfig {
	// Custom log transport
	transport?: (logEntry: LogEntry) => Promise<void>;
 
	// Transform log data before sending
	transformLogData?: (action: LoggerAction, context: ActionExecutionContext) => any;
 
	// Filter which logs to send
	shouldLog?: (action: LoggerAction, context: ActionExecutionContext) => boolean;
 
	// Called after successful logging
	onLogSent?: (logEntry: LogEntry, context: ActionExecutionContext) => void;
 
	// Error handling
	onError?: (error: Error, context: ActionExecutionContext) => void;
}
 
export interface LogEntry {
	timestamp: string;
	formId: string;
	trigger: string;
	level: string;
	message?: string;
	formData?: any;
	userAgent?: string;
	tags: string[];
	customData?: any;
}

Step 3: Implement the Plugin Factory

import { LoggerActionSchema, type LoggerAction } from './logger-schema';
import type { LoggerPluginConfig, LogEntry } from './logger-config';
import type { ActionExecutionContext, ActionExecutionResult } from '@schema-engine/actions';
 
export function loggerPlugin(config: LoggerPluginConfig = {}) {
  const {
    transport = defaultTransport,
    transformLogData = (action, context) => ({}),
    shouldLog = () => true,
    onLogSent = () => {},
    onError = (error) => console.error('Logger plugin error:', error)
  } = config;
 
  return {
    type: 'logger' as const,
    schema: LoggerActionSchema,
    defaultTriggers: ['form_submit'] as const,
    handler: async (
      action: LoggerAction,
      context: ActionExecutionContext
    ): Promise<ActionExecutionResult> => {
      try {
        // Check if we should log this event
        if (!shouldLog(action, context)) {
          return { success: true, message: 'Logging skipped' };
        }
 
        // Build log entry
        const logEntry: LogEntry = {
          timestamp: new Date().toISOString(),
          formId: context.formId,
          trigger: context.trigger,
          level: action.logLevel,
          message: action.message,
          tags: action.tags,
          customData: transformLogData(action, context)
        };
 
        // Include optional data
        if (action.includeFormData) {
          logEntry.formData = context.formData;
        }
 
        if (action.includeUserAgent && typeof navigator !== 'undefined') {
          logEntry.userAgent = navigator.userAgent;
        }
 
        // Send log entry
        await transport(logEntry);
 
        // Notify success
        onLogSent(logEntry, context);
 
        return {
          success: true,
          message: \`Log sent: \${action.logLevel}\`,
          data: { logEntry }
        };
 
      } catch (error) {
        const err = error instanceof Error ? error : new Error(String(error));
        onError(err, context);
 
        return {
          success: false,
          error: \`Logging failed: \${err.message}\`
        };
      }
    }
  };
}
 
// Default transport (console + optional remote)
async function defaultTransport(logEntry: LogEntry) {
  console.log(\`[\${logEntry.level.toUpperCase()}] FormEngine:\`, logEntry);
 
  // Could also send to remote service
  if (process.env.NODE_ENV === 'production') {
    try {
      await fetch('/api/form-logs', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(logEntry)
      });
    } catch (error) {
      console.warn('Failed to send log to remote service:', error);
    }
  }
}`}
/>
 
### Step 4: Type Declaration
 
For TypeScript support, add module augmentation:
 
```typescript
// Add to your types file or at the end of the plugin file
declare module '@schema-engine/actions' {
  interface PluginActions {
    logger: LoggerAction;
  }
}`}
/>
 
### Step 5: Register and Use the Plugin
 
```typescript
import { registerAction } from './my-registry';
import { loggerPlugin } from './logger-plugin';
 
// Register with custom configuration
registerAction(loggerPlugin({
  // Send logs to your analytics service
  transport: async (logEntry) => {
    await analytics.track('form_interaction', {
      formId: logEntry.formId,
      trigger: logEntry.trigger,
      timestamp: logEntry.timestamp,
      customData: logEntry.customData
    });
  },
 
  // Add custom data to logs
  transformLogData: (action, context) => ({
    userId: getCurrentUserId(),
    sessionId: getSessionId(),
    formStep: context.currentStep,
    fieldsCompleted: Object.keys(context.formData).length
  }),
 
  // Only log in production
  shouldLog: (action, context) => {
    return process.env.NODE_ENV === 'production';
  },
 
  // Success callback
  onLogSent: (logEntry, context) => {
    console.log(\`Logged interaction for form \${context.formId}\`);
  }
}));`}
/>
 
### Step 6: Use in Form Config
 
```typescript
const formWithLogging: FormConfig = {
  id: 'contact-form',
  title: 'Contact Us',
  steps: [/* ... */],
  actions: [
    {
      id: 'log-form-start',
      type: 'logger',
      logLevel: 'info',
      message: 'User started contact form',
      includeUserAgent: true,
      tags: ['contact', 'form-start'],
      triggers: ['form_mount']
    },
    {
      id: 'log-form-submit',
      type: 'logger',
      logLevel: 'info',
      message: 'User submitted contact form',
      includeFormData: false, // Don't log sensitive data
      tags: ['contact', 'form-submit'],
      triggers: ['form_submit']
    },
    {
      id: 'log-submit-error',
      type: 'logger',
      logLevel: 'error',
      message: 'Contact form submission failed',
      tags: ['contact', 'error'],
      triggers: ['form_submit_error']
    }
  ]
};

Advanced Plugin Patterns

Plugin with State Management

interface AnalyticsState {
	startTime: number;
	interactions: number;
	lastActivity: number;
}
 
// Plugin that tracks form session analytics
export function analyticsPlugin(config: AnalyticsConfig = {}) {
	// Plugin-level state (shared across all forms)
	const formSessions = new Map<string, AnalyticsState>();
 
	return {
		type: 'analytics' as const,
		schema: AnalyticsActionSchema,
		defaultTriggers: ['form_mount', 'form_change', 'form_submit'] as const,
		handler: async (action, context) => {
			const { formId, trigger } = context;
 
			// Get or create session state
			let session = formSessions.get(formId);
			if (!session) {
				session = {
					startTime: Date.now(),
					interactions: 0,
					lastActivity: Date.now(),
				};
				formSessions.set(formId, session);
			}
 
			// Update state based on trigger
			switch (trigger) {
				case 'form_mount':
					session.startTime = Date.now();
					break;
				case 'form_change':
					session.interactions++;
					session.lastActivity = Date.now();
					break;
				case 'form_submit':
					const duration = Date.now() - session.startTime;
					await config.onSubmit?.({
						formId,
						duration,
						interactions: session.interactions,
					});
					// Clean up session
					formSessions.delete(formId);
					break;
			}
 
			return { success: true };
		},
	};
}

Plugin with External Dependencies

import { z } from 'zod';
 
// Plugin that integrates with external services
export function integrationPlugin(config: IntegrationConfig) {
  // Validate external dependencies
  if (!config.apiClient) {
    throw new Error('Integration plugin requires apiClient');
  }
 
  return {
    type: 'integration' as const,
    schema: IntegrationActionSchema,
    defaultTriggers: ['form_submit_success'] as const,
    handler: async (action, context) => {
      try {
        // Use external service
        const result = await config.apiClient.createContact({
          formId: context.formId,
          data: context.formData,
          source: action.source || 'form-engine'
        });
 
        // Handle webhooks if configured
        if (action.webhookUrl) {
          await fetch(action.webhookUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              event: 'form_submitted',
              formId: context.formId,
              contactId: result.id,
              timestamp: new Date().toISOString()
            })
          });
        }
 
        return {
          success: true,
          message: 'Integration completed',
          data: { contactId: result.id }
        };
 
      } catch (error) {
        return {
          success: false,
          error: \`Integration failed: \${error.message}\`
        };
      }
    }
  };
}
 
interface IntegrationConfig {
  apiClient: {
    createContact: (data: any) => Promise<{ id: string }>;
  };
  onSuccess?: (contactId: string) => void;
  onError?: (error: Error) => void;
}

Async Plugin with Progress

export function uploadPlugin(config: UploadConfig = {}) {
  return {
    type: 'upload' as const,
    schema: UploadActionSchema,
    defaultTriggers: ['form_submit'] as const,
    handler: async (action, context) => {
      try {
        const files = extractFiles(context.formData);
        if (files.length === 0) {
          return { success: true, message: 'No files to upload' };
        }
 
        // Show progress if configured
        config.onProgress?.({ uploaded: 0, total: files.length });
 
        const uploadPromises = files.map(async (file, index) => {
          const result = await uploadFile(file, action.uploadEndpoint);
          config.onProgress?.({ uploaded: index + 1, total: files.length });
          return result;
        });
 
        const uploadResults = await Promise.all(uploadPromises);
 
        return {
          success: true,
          message: \`Uploaded \${files.length} files\`,
          data: { uploadResults }
        };
 
      } catch (error) {
        return {
          success: false,
          error: \`Upload failed: \${error.message}\`
        };
      }
    }
  };
}
 
interface UploadConfig {
  onProgress?: (progress: { uploaded: number; total: number }) => void;
  onComplete?: (results: any[]) => void;
}

Testing Custom Plugins

Unit Testing

import { describe, test, expect, vi } from 'vitest';
import { loggerPlugin } from './logger-plugin';
import type { ActionExecutionContext } from '@/lib/form-engine/actions/types';
 
describe('loggerPlugin', () => {
	test('should log with default transport', async () => {
		const consoleSpy = vi.spyOn(console, 'log');
		const plugin = loggerPlugin();
 
		const action = {
			id: 'test-log',
			type: 'logger' as const,
			logLevel: 'info' as const,
			message: 'Test message',
			tags: ['test'],
		};
 
		const context: ActionExecutionContext = {
			formId: 'test-form',
			trigger: 'form_submit',
			formData: { name: 'John' },
			isValid: true,
		};
 
		const result = await plugin.handler(action, context);
 
		expect(result.success).toBe(true);
		expect(consoleSpy).toHaveBeenCalledWith(
			expect.stringContaining('[INFO] FormEngine:'),
			expect.objectContaining({
				formId: 'test-form',
				trigger: 'form_submit',
				message: 'Test message',
			}),
		);
	});
 
	test('should use custom transport', async () => {
		const mockTransport = vi.fn();
		const plugin = loggerPlugin({ transport: mockTransport });
 
		const action = {
			id: 'test-log',
			type: 'logger' as const,
			logLevel: 'info' as const,
		};
 
		const context: ActionExecutionContext = {
			formId: 'test-form',
			trigger: 'form_submit',
			formData: {},
			isValid: true,
		};
 
		await plugin.handler(action, context);
 
		expect(mockTransport).toHaveBeenCalledWith(
			expect.objectContaining({
				formId: 'test-form',
				trigger: 'form_submit',
			}),
		);
	});
});

Integration Testing

import { describe, test, expect, beforeEach } from 'vitest';
import { describe, test, expect, beforeEach } from 'vitest';
import { ActionRegistry, ActionExecutor } from '@schema-engine/actions';
import { loggerPlugin } from './logger-plugin';
 
describe('Logger Plugin Integration', () => {
	beforeEach(() => {
		actionRegistry.clear();
	});
 
	test('should work with action executor', async () => {
		const logsSent: any[] = [];
 
		registerAction(
			loggerPlugin({
				transport: async (logEntry) => {
					logsSent.push(logEntry);
				},
			}),
		);
 
		const executor = new ActionExecutor();
		const actions = [
			{
				id: 'log-submit',
				type: 'logger',
				logLevel: 'info',
				message: 'Form submitted',
			},
		];
 
		await executor.executeTrigger(actions, 'form_submit', {
			formId: 'test-form',
			formData: { name: 'Test' },
			isValid: true,
		});
 
		expect(logsSent).toHaveLength(1);
		expect(logsSent[0]).toMatchObject({
			formId: 'test-form',
			trigger: 'form_submit',
			level: 'info',
			message: 'Form submitted',
		});
	});
});

Plugin Best Practices

1. Configuration Validation

export function robustPlugin(config: RobustConfig) {
	// Validate config at plugin creation time
	if (!config.apiKey) {
		throw new Error('robustPlugin requires apiKey in config');
	}
 
	if (config.retries && (config.retries < 0 || config.retries > 5)) {
		throw new Error('retries must be between 0 and 5');
	}
 
	// Provide sensible defaults
	const finalConfig = {
		retries: 3,
		timeout: 5000,
		...config,
	};
 
	return {
		type: 'robust' as const,
		handler: async (action, context) => {
			// Use validated config
			return performActionWithRetries(action, context, finalConfig);
		},
	};
}

2. Error Handling

export function safePlugin(config: SafeConfig = {}) {
	return {
		type: 'safe' as const,
		handler: async (action, context) => {
			try {
				// Your plugin logic
				const result = await performAction(action, context);
				return { success: true, data: result };
			} catch (error) {
				// Log error details
				console.error('SafePlugin error:', {
					error: error.message,
					formId: context.formId,
					actionId: action.id,
					trigger: context.trigger,
				});
 
				// Call error handler if provided
				config.onError?.(error, context);
 
				// Return graceful failure
				return {
					success: false,
					error: config.userFriendlyErrors ? 'Something went wrong. Please try again.' : error.message,
					shouldContinue: config.continueOnError !== false,
				};
			}
		},
	};
}

3. TypeScript Support

// Always provide proper TypeScript support
export interface MyPluginConfig {
	apiKey: string;
	timeout?: number;
	onSuccess?: (data: any, context: ActionExecutionContext) => void;
	onError?: (error: Error, context: ActionExecutionContext) => void;
}
 
// Export the action type
export type MyAction = z.infer<typeof MyActionSchema>;
 
// Module augmentation for global types
declare module '@schema-engine/actions' {
	interface PluginActions {
		my_action: MyAction;
	}
}
 
// Type-safe plugin factory
export function myPlugin(config: MyPluginConfig) {
	return {
		type: 'my_action' as const,
		schema: MyActionSchema,
		defaultTriggers: ['form_submit'] as const,
		handler: async (action: MyAction, context: ActionExecutionContext): Promise<ActionExecutionResult> => {
			// Implementation with full type safety
		},
	};
}

Custom plugins let you extend Form Engine with any behavior your forms need. Follow these patterns for reliable, maintainable plugins that integrate seamlessly with the action system.