Example Plugin

A fully-featured reference plugin that ships with every new SonicJS app. Read it to learn the plugin system, then delete it and build your own.


Overview

The example plugin demonstrates every major extension point in the SonicJS v3 plugin system. It is not a toy — it uses real routes, real collections, real settings, and real data seeding patterns that you can copy directly into your own plugins.

🌐

Public API Routes

GET /example, GET /example/:name, GET /example/moods — served at a top-level path to avoid the /api/:collection catch-all

🖥️

Admin Page

Custom admin page at /admin/example with automatic sidebar menu entry

📋

Moods Collection

Code-defined collection registered via registerCollections() — no DB table required

⚙️

Schema-Driven Settings

configSchema auto-renders a settings form in /admin/plugins/example — no custom form needed

🔔

Lifecycle Hooks

Declarative subscriptions to content:after:create and auth:registration:completed events

🌱

Idempotent Data Seeding

onBoot seeds three default moods with a COUNT guard — safe across isolate restarts

Remove the example plugin from src/index.ts and delete src/plugins/example/ when you're ready to ship your own. It has no production use — it's a learning aid.


File Structure

src/plugins/example/
├── index.ts                      ← definePlugin() entry point (start here)
├── routes/
│   ├── api.ts                    ← public Hono routes
│   └── admin.ts                  ← admin page route
└── collections/
    └── moods.collection.ts       ← CollectionConfig for the Moods collection

Wired into src/index.ts:

import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import { examplePlugin } from './plugins/example'
import { moodsCollection } from './plugins/example/collections/moods.collection'

registerCollections([moodsCollection])

export default createSonicJSApp({
  plugins: { register: [examplePlugin] },
})

Plugin Definition

Every SonicJS plugin is a PluginConfig object created by definePlugin(). The identity fields are set once and never changed:

export const examplePlugin = definePlugin({
  id: 'example',        // stable machine key — used as DB key, URL segment, dependency ref
  name: 'Example',      // displayed in /admin/plugins list
  version: '1.0.0',
  description: 'A demo plugin that explains the SonicJS v3 plugin system.',
  sonicjsVersionRange: '^3.0.0',
  author: { name: 'You', email: 'you@example.com', url: 'https://example.com' },
  // ...
})

The id becomes:

  • The key under which settings are stored in the documents table
  • The URL segment in /admin/plugins/example
  • The identifier used by other plugins that declare dependencies: ['example']

Two-Phase Boot

SonicJS plugins initialize in two strict phases that match the Cloudflare Workers execution model:

// Phase 1 — SYNCHRONOUS — must complete before any request arrives
register(app) {
  app.route('/example', createExampleApiRoutes(pluginOptions) as any)
  app.route('/admin/example', createExampleAdminRoutes(pluginOptions) as any)
},

// Phase 2 — ASYNC — runs once per Worker isolate on the first request
async onBoot(ctx) {
  // safe to: await DB queries, read env bindings, register conditional hooks
},

Why two phases? Hono locks its router after the first request. Routes added after that are silently ignored. register() must be synchronous and mount all routes while the app is still initializing. onBoot() handles everything requiring I/O: loading settings from D1, seeding default data, registering conditional hooks.

The pluginOptions object is passed by reference from register into onBoot. When onBoot mutates pluginOptions.greeting, the route handlers created in register see the new value immediately — no restart required.


Public Routes

Routes live at /example/*not /api/example/*. User plugins mount after the core /api/:collection catch-all. In Hono, routes match in registration order, so any path starting with /api/ gets swallowed before your handler runs. Use a top-level prefix.

routes/api.ts

export function createExampleApiRoutes(
  options: { greeting?: string; defaultName?: string } = {}
): Hono {
  const router = new Hono()

  // GET /example — greeting with random mood
  router.get('/', async (c) => {
    const db = (c.env as any)?.DB
    const mood = db ? await getRandomMood(db) : null
    return c.json({
      message: `Hello, ${options.defaultName ?? 'Stranger'}! The world is still quite cruel.`,
      mood: mood ? `${mood.emoji} ${mood.name}`.trim() : null,
      moodDescription: mood?.description ?? null,
      timestamp: new Date().toISOString(),
      plugin: 'example',
    })
  })

  // GET /example/moods — must be BEFORE /:name
  router.get('/moods', async (c) => {
    const repo = new DocumentRepository((c.env as any).DB, 'default')
    const docs = await repo.list({ typeId: 'example', status: 'published', limit: 100 })
    return c.json({ moods: docs.map(d => d.data), total: docs.length })
  })

  // GET /example/:name — personalised greeting (registered AFTER /moods)
  router.get('/:name', async (c) => {
    const name = c.req.param('name')
    // ... same pattern as GET /
  })

  return router
}

Route order matters: /moods must be registered before /:name. Hono matches in registration order, so if /:name came first, GET /example/moods would be caught as a name param.

DocumentRepository is the read chokepoint for all document data. Pass it the D1 binding and tenant ID; it handles SQL, tenant scoping, and status filtering (R4 from the architecture rules).

API Response Examples

GET /example

curl http://localhost:8787/example

GET /example/:name

curl http://localhost:8787/example/Alice

GET /example/moods

curl http://localhost:8787/example/moods

Admin Page

The admin route is mounted at /admin/example and registered in register() alongside the API routes:

register(app) {
  app.route('/example', createExampleApiRoutes(pluginOptions) as any)
  app.route('/admin/example', createExampleAdminRoutes(pluginOptions) as any)
},

The sidebar entry is declared in the plugin's menu array:

menu: [
  {
    label: 'Example',
    path: '/admin/example',
    icon: 'globe-alt',
    order: 90,
    permissions: ['admin'],
  },
],

The catalyst admin layout reads menu from the plugin registry and renders the sidebar entry automatically. No middleware or template modifications required.


Moods Collection

Collections in SonicJS v3 are code-only — no DB table. You define a CollectionConfig, pass it to registerCollections(), and the bootstrap sequence auto-registers a document_type row. The admin content UI at /admin/content?model=example appears automatically.

collections/moods.collection.ts

export const moodsCollection = {
  name: 'example',           // must match typeId used in DocumentRepository queries
  displayName: 'Example',
  slug: 'example',
  description: 'Moods served by the Example plugin API',
  icon: '😈',

  schema: {
    type: 'object',
    properties: {
      name:        { type: 'string', title: 'Mood Name', required: true, maxLength: 100 },
      emoji:       { type: 'string', title: 'Emoji', maxLength: 10 },
      description: { type: 'string', title: 'Description', maxLength: 300 },
    },
    required: ['name'],
  },

  listFields:      ['name', 'emoji', 'description'],
  searchFields:    ['name', 'description'],
  defaultSort:     'createdAt',
  defaultSortOrder: 'asc' as const,
  managed:  true,
  isActive: true,

  access: {
    public: ['read'],   // readable by GET /example/moods without auth
  },
} satisfies CollectionConfig

The name: 'example' ties the collection to the typeId used everywhere else: DocumentRepository.list({ typeId: 'example' }), DocumentsService.create({ typeId: 'example', ... }), and the seeding COUNT guard. Keep them consistent.


Settings

Declaring configSchema in the plugin definition auto-renders a settings form at /admin/plugins/example — no custom Hono handler needed.

configSchema: {
  greeting: {
    type: 'string',
    label: 'Custom Greeting',
    default: 'Hello, Cruel World!',
  },
  defaultName: {
    type: 'string',
    label: 'Default Name',
    default: 'Stranger',
  },
  showTimestamp: {
    type: 'boolean',
    label: 'Show Timestamp in API Response',
    default: true,
  },
  mood: {
    type: 'select',
    label: 'Mood',
    options: [
      { value: 'cruel',       label: '😈 Cruel (default)' },
      { value: 'kind',        label: '😇 Kind' },
      { value: 'indifferent', label: '😐 Indifferent' },
    ],
    default: 'cruel',
  },
},

The core validates and persists submitted form data to the documents table. On the next onBoot, your plugin reads the saved values and applies them:

async onBoot(ctx) {
  const existing = await svc.getPlugin('example')
  const existingSettings = (existing?.settings as Record<string, unknown>) ?? {}
  const mergedSettings = {
    greeting:    'Hello, Cruel World!',   // default
    defaultName: 'Stranger',              // default
    ...existingSettings,                  // saved values win
  }
  if (typeof mergedSettings.greeting === 'string') {
    pluginOptions.greeting = mergedSettings.greeting
  }
  // Always write back with _adminPath and _routes preserved:
  await svc.updatePluginSettings('example', {
    ...mergedSettings,
    _adminPath: '/admin/example',
    _routes: [ /* route list */ ],
  })
}

Always spread existingSettings after defaults so saved values win. Always include _adminPath and _routes in the write-back — the admin plugin info tab reads them.


Hooks

Hooks are the declarative way to subscribe to typed lifecycle events. The hooks map in the plugin definition is always active:

hooks: {
  'content:after:create': (payload) => {
    console.log(`[example] New content in "${payload.collection}"`)
  },
  'auth:registration:completed': (payload) => {
    console.log(`[example] Welcome, ${payload.user.email}!`)
  },
},

For conditional subscriptions (e.g., only subscribe when a config flag is on), use ctx.hooks.on() inside onBoot instead:

async onBoot(ctx) {
  if (pluginOptions.verboseLogging) {
    ctx.hooks.on('content:after:update', (payload) => {
      console.log('[example] Content updated:', payload.id)
    })
  }
}

Data Seeding

onBoot seeds three default moods on first run. A COUNT guard prevents duplicates across isolate restarts:

async onBoot(ctx) {
  const db = ctx.env?.DB

  const countRow = await db
    .prepare(`SELECT COUNT(*) as n FROM documents WHERE type_id = ? AND tenant_id = ?`)
    .bind('example', 'default')
    .first()

  if ((countRow?.n ?? 0) === 0) {
    for (const mood of DEFAULT_MOODS) {
      await svc.create(
        { typeId: 'example', data: mood, publishOnCreate: true },
        'system'
      )
    }
  }
}

DocumentsService.create() handles the raw SQL batch (R1), tenant scoping (R3), and version numbering (R6). The publishOnCreate: true flag makes the moods immediately available via GET /example/moods without a separate publish step.

The three default moods:

NameEmojiDescription
Cruel😈Default adversarial energy.
Kind😇Surprising warmth from an unexpected source.
Chaotic🌀Unpredictable in the best possible way.

Customizing

Change the defaults by editing src/plugins/example/index.ts:

// Change the seed data
const DEFAULT_MOODS = [
  { name: 'Caffeinated', emoji: '☕', description: 'Monday morning energy.' },
  { name: 'Focused',     emoji: '🎯', description: 'Deep work mode.' },
]

// Change the plugin identity (do this first)
export const examplePlugin = definePlugin({
  id: 'my-plugin',   // ← pick a stable id before shipping
  name: 'My Plugin',
  // ...
})

Or configure via the admin UI without touching code: Admin → Plugins → Example → Settings.


Removing

When you're ready to build your own plugin, remove the example from src/index.ts:

// Remove these imports
import { examplePlugin } from './plugins/example'
import { moodsCollection } from './plugins/example/collections/moods.collection'

// Remove from registerCollections([...])
// Remove examplePlugin from plugins.register: [...]

Then delete src/plugins/example/. The moods documents in D1 remain until you clean them up manually — they don't affect other collections.

Was this page helpful?