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 filemanifest.json- Plugin metadata and configurationroutes.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
| Option | Type | Description |
|---|---|---|
description | string | Route description |
requiresAuth | boolean | Require authentication |
roles | string[] | Required user roles |
priority | number | Route 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 initializationapp:ready- App ready to receive requestsapp:shutdown- App shutting down
Request Lifecycle
request:start- Request beginsrequest:end- Request completesrequest:error- Request error occurs
Authentication
auth:login- User logs inauth:logout- User logs outauth:register- User registers
Content Lifecycle
content:create- Content createdcontent:update- Content updatedcontent:delete- Content deletedcontent:publish- Content published
Media Lifecycle
media:upload- File uploadedmedia: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
| Option | Type | Description |
|---|---|---|
description | string | Service description |
dependencies | string[] | Required services |
singleton | boolean | Single 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
| Option | Type | Description |
|---|---|---|
icon | string | Heroicon name |
order | number | Sort order (lower = earlier) |
parent | string | Parent menu label |
permissions | string[] | 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:
- Check the export - Ensure your plugin is exported from
src/plugins/index.ts - Verify the structure - Make sure your plugin has
nameandversionproperties - Check for errors - Look for validation errors in the console during startup
- 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