Plugin Development Guide

Create custom plugins to extend SonicJS with new features, routes, admin pages, and more.


Overview

The SonicJS plugin system provides a powerful, modular architecture for extending functionality. Plugins can:

  • Add custom API routes
  • Register middleware
  • Create admin pages and menu items
  • Define database models
  • Hook into system events
  • Provide reusable services

Key Features:

🔌

Modular Architecture

Self-contained plugins with routes, services, and templates

🪝

Event-Driven

Hook into content, auth, and system events

Hot-Swappable

Enable or disable plugins without restarting

🛡️

Isolated

Scoped hooks prevent cross-plugin interference


Getting Started

Prerequisites

  • TypeScript knowledge
  • Understanding of Hono.js framework
  • Familiarity with SonicJS architecture
  • Node.js 18+ installed

Plugin Location

Custom plugins live in your app's src/plugins/ directory. When you create a SonicJS app, this folder is set up for you to add your own plugins.


Plugin Structure

A typical plugin has this structure:

  • index.ts - Main plugin file
  • manifest.json - Plugin metadata and configuration
  • routes.ts - API routes (optional)
  • services/ - Business logic services (optional)
  • migrations/ - Database migrations (optional)
  • tests/ - Plugin tests (optional)

Creating a Plugin

Let's create a simple plugin step by step.

Step 1: Create the Plugin Directory

mkdir -p src/plugins/my-plugin

Step 2: Create the Manifest

Create manifest.json with your plugin metadata:

{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "description": "My first SonicJS plugin",
  "author": "Your Name",
  "license": "MIT",
  "category": "utilities",
  "permissions": {
    "my-plugin:view": "View My Plugin page"
  },
  "adminMenu": {
    "label": "My Plugin",
    "icon": "puzzle-piece",
    "path": "/admin/my-plugin",
    "order": 100
  }
}

Step 3: Create the Plugin Code

Create index.ts:

import { Hono } from 'hono'
import { html } from 'hono/html'
import { PluginBuilder } from '@sonicjs-cms/core'
import type { Plugin } from '@sonicjs-cms/core'

export function createMyPlugin(): Plugin {
  const builder = PluginBuilder.create({
    name: 'my-plugin',
    version: '1.0.0',
    description: 'My first SonicJS plugin'
  })

  builder.metadata({
    author: { name: 'Your Name', email: 'you@example.com' },
    license: 'MIT',
    compatibility: '^2.0.0'
  })

  const routes = new Hono()

  routes.get('/', async (c: any) => {
    const user = c.get('user')
    return c.html(html`<h1>Hello from My Plugin!</h1>`)
  })

  builder.addRoute('/admin/my-plugin', routes, {
    description: 'My plugin admin page',
    requiresAuth: true
  })

  builder.addMenuItem('My Plugin', '/admin/my-plugin', {
    icon: 'puzzle-piece',
    order: 100,
    permissions: ['my-plugin:view']
  })

  builder.lifecycle({
    activate: async () => console.info('My plugin activated!'),
    deactivate: async () => console.info('My plugin deactivated!')
  })

  return builder.build() as Plugin
}

export const myPlugin = createMyPlugin()

Step 4: Register the Plugin

Add your plugin to your app's plugin index at src/plugins/index.ts:

export { myPlugin } from './my-plugin'

Your plugin will be automatically loaded when the app starts.

Alternative: Plain Object Pattern

If you prefer a simpler approach without the builder, you can create plugins as plain objects that implement the Plugin interface:

import type { Plugin } from '@sonicjs-cms/core'

export default {
  name: 'my-plugin',
  version: '1.0.0',
  description: 'My first SonicJS plugin',

  hooks: [{
    name: 'content:save',
    handler: async (data) => {
      console.log('Content saved:', data.id)
      return data
    }
  }],

  async activate() {
    console.log('My plugin activated!')
  },

  async deactivate() {
    console.log('My plugin deactivated!')
  }
} satisfies Plugin

Both approaches produce the same result - choose whichever feels more natural for your use case. The PluginBuilder provides better IDE autocomplete and guided development, while plain objects are more concise for simple plugins.


Routes

Routes define API endpoints using Hono.js.

Basic Route Registration

import { Hono } from 'hono'

const routes = new Hono()

routes.get('/items', async (c) => {
  return c.json({ success: true, data: [] })
})

routes.post('/items', async (c) => {
  const body = await c.req.json()
  return c.json({ success: true, data: body }, 201)
})

builder.addRoute('/api/my-plugin', routes, {
  description: 'My plugin API',
  requiresAuth: true
})

Route Options

OptionTypeDescription
descriptionstringRoute description
requiresAuthbooleanRequire authentication
rolesstring[]Required user roles
prioritynumberRoute priority (lower = earlier)

Lifecycle Hooks

Plugins have five lifecycle stages:

1. Install

Called once when the plugin is first installed.

builder.lifecycle({
  install: async (context) => {
    await context.db.exec(`
      CREATE TABLE IF NOT EXISTS my_plugin_data (
        id INTEGER PRIMARY KEY,
        key TEXT NOT NULL,
        value TEXT
      )
    `)
  }
})

2. Activate

Called when the plugin is activated.

builder.lifecycle({
  activate: async (context) => {
    context.hooks.register('content:save', async (data) => {
      console.info('Content saved:', data.id)
      return data
    })
  }
})

3. Configure

Called when plugin configuration changes.

builder.lifecycle({
  configure: async (config) => {
    if (config.apiKey && config.apiKey.length < 10) {
      throw new Error('Invalid API key')
    }
  }
})

4. Deactivate

Called when the plugin is deactivated.

builder.lifecycle({
  deactivate: async (context) => {
    // Clean up resources
  }
})

5. Uninstall

Called when the plugin is removed.

builder.lifecycle({
  uninstall: async (context) => {
    await context.db.exec('DROP TABLE IF EXISTS my_plugin_data')
  }
})

Hook System

The hook system provides event-driven extensibility.

Standard Hooks

SonicJS provides these built-in hooks:

Application Lifecycle

  • app:init - App initialization
  • app:ready - App ready to receive requests
  • app:shutdown - App shutting down

Request Lifecycle

  • request:start - Request begins
  • request:end - Request completes
  • request:error - Request error occurs

Authentication

  • auth:login - User logs in
  • auth:logout - User logs out
  • auth:register - User registers

Content Lifecycle

  • content:create - Content created
  • content:update - Content updated
  • content:delete - Content deleted
  • content:publish - Content published

Media Lifecycle

  • media:upload - File uploaded
  • media:delete - File deleted

Registering Hooks

builder.addHook('content:save', async (data, context) => {
  console.info('Content saved:', data.id)
  data.processedAt = Date.now()
  return data
})

// Hook with priority (lower = earlier)
builder.addHook('content:save', async (data) => {
  data.validated = true
  return data
}, { priority: 5 })

Custom Hooks

builder.addHook('my-plugin:data-processed', async (data) => {
  console.info('Data processed:', data)
  return data
})

Services

Services encapsulate business logic.

Creating a Service

export class EmailService {
  private apiKey: string

  constructor(apiKey: string) {
    this.apiKey = apiKey
  }

  async sendEmail(to: string, subject: string, body: string) {
    // Email sending logic
    return true
  }
}

Registering Services

const emailService = new EmailService('api-key-here')

builder.addService('emailService', emailService, {
  description: 'Email sending service',
  singleton: true
})

Service Options

OptionTypeDescription
descriptionstringService description
dependenciesstring[]Required services
singletonbooleanSingle instance (default: true)

Database Models

Define database structures using Zod schemas and migrations.

Creating a Model

import { z } from 'zod'
import { PluginHelpers } from '@sonicjs-cms/core'

const userSchema = z.object({
  email: z.string().email(),
  firstName: z.string(),
  lastName: z.string(),
  active: z.boolean().default(true)
})

const migration = PluginHelpers.createMigration('plugin_users', [
  { name: 'id', type: 'INTEGER', primaryKey: true },
  { name: 'email', type: 'TEXT', nullable: false, unique: true },
  { name: 'first_name', type: 'TEXT', nullable: false },
  { name: 'last_name', type: 'TEXT', nullable: false },
  { name: 'active', type: 'INTEGER', nullable: false, defaultValue: '1' }
])

builder.addModel('PluginUser', {
  tableName: 'plugin_users',
  schema: userSchema,
  migrations: [migration]
})

Admin Interface

Add pages and menu items to the admin interface.

Admin Pages

builder.addAdminPage(
  '/my-plugin',
  'My Plugin',
  'MyPluginView',
  {
    description: 'Manage my plugin',
    permissions: ['my-plugin:view'],
    icon: 'cog'
  }
)

Menu Items

builder.addMenuItem('My Plugin', '/admin/my-plugin', {
  icon: 'puzzle-piece',
  order: 50,
  permissions: ['my-plugin:view']
})

// Sub-menu item
builder.addMenuItem('Settings', '/admin/my-plugin/settings', {
  icon: 'cog',
  parent: 'My Plugin',
  order: 1
})

Menu Item Options

OptionTypeDescription
iconstringHeroicon name
ordernumberSort order (lower = earlier)
parentstringParent menu label
permissionsstring[]Required permissions

Plugin Context

Plugins receive a context object with access to SonicJS APIs.

Context Interface

interface PluginContext {
  db: D1Database          // Cloudflare D1 database
  kv: KVNamespace         // Cloudflare KV storage
  r2?: R2Bucket           // Cloudflare R2 storage
  config: PluginConfig    // Plugin configuration
  services: {
    auth: AuthService
    content: ContentService
    media: MediaService
  }
  hooks: HookSystem
  logger: PluginLogger
}

Using the Database

builder.lifecycle({
  activate: async (context) => {
    const result = await context.db.prepare(
      'SELECT * FROM my_table WHERE active = ?'
    ).bind(1).all()
  }
})

Using KV Storage

builder.lifecycle({
  activate: async (context) => {
    await context.kv.put('my-plugin:config', JSON.stringify(config))
    const stored = await context.kv.get('my-plugin:config', 'json')
  }
})

Using the Logger

builder.lifecycle({
  activate: async (context) => {
    context.logger.debug('Debug message', { detail: 'value' })
    context.logger.info('Plugin activated')
    context.logger.warn('Warning message')
    context.logger.error('Error occurred', new Error('Details'))
  }
})

Best Practices

1. Plugin Naming

Use lowercase with hyphens: weather-forecast, user-analytics

2. Semantic Versioning

Follow semver: 1.0.0 (initial), 1.1.0 (feature), 1.1.1 (fix), 2.0.0 (breaking)

3. Provide Sensible Defaults

builder.lifecycle({
  configure: async (config) => {
    const settings = {
      enabled: true,
      cacheEnabled: true,
      cacheTTL: 3600,
      ...config
    }
  }
})

4. Validate Inputs

import { z } from 'zod'

const inputSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100)
})

routes.post('/users', async (c) => {
  const body = await c.req.json()
  const validated = inputSchema.parse(body)
  return c.json({ success: true, data: validated })
})

5. Handle Errors Gracefully

routes.get('/data', async (c) => {
  try {
    const data = await fetchData()
    return c.json({ success: true, data })
  } catch (error) {
    console.error('Failed to fetch data:', error)
    return c.json({ success: false, error: 'Failed to fetch data' }, 500)
  }
})

6. Clean Up Resources

builder.lifecycle({
  deactivate: async (context) => {
    if (refreshInterval) clearInterval(refreshInterval)
    cache.clear()
  }
})

Testing

Unit Testing

import { describe, it, expect } from 'vitest'
import { myPlugin } from '../index'

describe('My Plugin', () => {
  it('should have correct metadata', () => {
    expect(myPlugin.name).toBe('my-plugin')
    expect(myPlugin.version).toBe('1.0.0')
  })

  it('should register routes', () => {
    expect(myPlugin.routes).toBeDefined()
  })
})

Running Tests

npm run test

Troubleshooting

"PLUGIN_REGISTRY configuration" Message

If you see this console message during startup:

[PluginService] ensureAllPluginsExist - requires PLUGIN_REGISTRY configuration

This is an informational message, not an error. It indicates that the plugin service is running without a custom plugin registry configured. This is normal for most applications.

When you can ignore this:

  • You're using the built-in plugin system as-is
  • Your plugins are loading and working correctly
  • You don't need dynamic plugin discovery

When you might need to configure it:

  • You're building a plugin marketplace
  • You need to auto-discover plugins from external sources
  • You're implementing custom plugin installation workflows

For advanced plugin registry configuration, see the Plugin System Architecture documentation.

Plugin Not Loading

If your plugin isn't being loaded:

  1. Check the export - Ensure your plugin is exported from src/plugins/index.ts
  2. Verify the structure - Make sure your plugin has name and version properties
  3. Check for errors - Look for validation errors in the console during startup
  4. Restart the dev server - Some changes require a full restart

TypeScript Errors

If you get type errors with the Plugin interface:

// Use 'satisfies' for type checking while keeping inference
export default { ... } satisfies Plugin

// Or use explicit type annotation
const myPlugin: Plugin = { ... }
export default myPlugin

Next Steps

Was this page helpful?