Skip to content

Plugin authoring

Package server hooks, browser components, routes, migrations, and diagnostics.

An installable Adonia plugin describes its server, browser, migration, route, worker, doctor, and compatibility surfaces in one manifest. Ship the server entry from . and, when descriptors use custom component types, the browser entry from ./client at the same package version. Use vendor-prefixed component keys such as acme/currency; adonia/* is reserved.

Define a manifest

definePlugin() preserves the manifest’s literal types. Panel.plugins() performs the runtime checks: protocolVersion must equal Adonia’s current protocol, version must be valid semver, and compat.adonia must include the installed @adonia/core version.

import { fileURLToPath } from 'node:url'
import {
  definePlugin,
  PROTOCOL_VERSION,
  type AdoniaPluginManifest,
} from '@adonia/core'

export const auditPlugin: AdoniaPluginManifest = definePlugin({
  name: '@acme/adonia-audit',
  version: '1.0.0',
  protocolVersion: PROTOCOL_VERSION,
  compat: { adonia: '>=0.0.1-alpha.0 <1.0.0' },
  client: '@acme/adonia-audit/client',
  migrations: [
    fileURLToPath(new URL('./migrations', import.meta.url)),
  ],
})

Migration entries are absolute directories, not individual files. Keep the directory in the published package. Source packages commonly point at ./migrations; compiled packages then publish the corresponding dist/migrations directory.

Attach manifests in order. Server hooks and runtime hooks preserve that order.

import { Panel } from '@adonia/core'
import { auditPlugin } from '#adonia/plugins/audit_plugin'

export default Panel.make('admin').plugins([auditPlugin])

A protocol mismatch or a failing/invalid compatibility range throws E_ADONIA_PLUGIN while the panel is registered, before routes or workers can serve traffic.

Server hook

server(registry) runs once for every attached panel during provider boot. The registry exposes the current panel and Adonis app, forwards ordered hooks through registry.on(), and adds resources without replacing host registrations.

import { definePlugin, PROTOCOL_VERSION } from '@adonia/core'
import AuditResource from '#adonia/resources/audit_resource'

export const auditPlugin = definePlugin({
  name: '@acme/adonia-audit',
  version: '1.0.0',
  protocolVersion: PROTOCOL_VERSION,
  server(registry) {
    registry.resource(AuditResource)
    registry.on('record:saved', ({ resourceClass, operation }) => {
      console.info(resourceClass.slug, operation)
    })
  },
})

The protocol-v1 events remain resource:booted, descriptor:compiling, pipeline:building, record:saving, record:saved, record:deleting, record:deleted, action:executing, action:executed, action:failed, nav:building, and search:results. Import ADONIA_HOOK_EVENTS instead of maintaining a second list.

pipeline.before(stage, fn), after(stage, fn), and replace(stage, fn) use the addressable stages base, softDeleteScope, search, filters, sort, and eagerLoad. A replacement must preserve panel, tenant, parent, lens, and soft-delete isolation.

Server and route failures abort startup as E_ADONIA_PLUGIN. Ordinary non-record hook failures are logged and isolated. Record hooks propagate inside the write transaction so the transaction rolls back.

Legacy server plugins

The pre-manifest AdoniaPlugin shape remains source-compatible: register(panel, app) still runs once per attached panel, boot(app) still runs once per plugin name per process, and cacheKey still participates in descriptor cache identity. New packages should use the manifest so tooling, routes, workers, and compatibility are declared together.

Scoped routes

Manifest routes are created inside the protected panel route group. The callback cannot escape the panel’s domain, prefix, tenant middleware, or access gate. Each method takes a relative pattern, a handler, and a manifest-local name:

import type { HttpContext } from '@adonisjs/core/http'
import type { PluginRouteContext } from '@adonia/core'

const exportAudit = (ctx: HttpContext) => ctx.response.noContent()

export function routes({ router }: PluginRouteContext): void {
  router.get('audit/export', exportAudit, 'export')
}

The example is named adonia.<panel>.plugin.@acme/adonia-audit.export. Leading slashes are normalized to relative paths. Route callbacks are synchronous because Adonis commits the route store immediately after provider boot.

Queue workers

workers maps reserved action names to ActionJobHandlers. Core dispatches the matching handler before its normal action worker and supplies the worker-process container, never an HTTP request:

import type { ActionJobHandler, ActionResult } from '@adonia/core'

const workers: Record<string, ActionJobHandler> = {
  'acme.audit.export': async (job, { container }) => {
    const worker = (await container.make('acme.auditWorker')) as {
      work(input: unknown): Promise<ActionResult>
    }
    return worker.work(job)
  },
}

Worker names must be unique within a panel. The host still starts its queue consumer and binds application-specific credentials and services; attaching the manifest removes manual action-name routing.

Migrations and doctor checks

Devtools consumes manifests structurally, so @adonia/core does not import the doctor runtime. A plugin configure hook can pass manifests to the installer:

import { configureAdoniaApp } from '@adonia/devtools/configure'
import { auditPlugin } from '#adonia/plugins/audit_plugin'

const appRoot = new URL('./', import.meta.url).pathname
await configureAdoniaApp(appRoot, { plugins: [auditPlugin] })

The installer copies every missing .ts, .js, .ts.stub, or .js.stub migration with an exclusive timestamped filename and registers the manifest’s doctor checks. A process that only needs diagnostics can call registerPluginDoctorChecks([auditPlugin]) from @adonia/devtools/doctor; repeated consumption of the same check object is safe, while a different check with the same id is rejected.

Client half

The client string is package metadata; the browser bundle does not import it automatically. Register the matching named UI plugin in the application’s component registry:

import type { FieldProps, NamedAdoniaUiPlugin } from '@adonia/ui'

function AuditField({ node, control }: FieldProps) {
  return (
    <output aria-label={String(node.props.label ?? node.key)}>
      {String(control.value ?? '')}
    </output>
  )
}

export const auditUiPlugin: NamedAdoniaUiPlugin = {
  name: '@acme/adonia-audit',
  version: '1.0.0',
  protocolVersion: 1,
  register(registry) {
    registry.field('acme/audit', AuditField)
  },
}
import { adonia } from '@adonia/ui'
import { auditUiPlugin } from '#adonia/plugins/audit_ui'

export default adonia({
  registry(registry) {
    registry.use(auditUiPlugin)
  },
})

A missing client registration does not crash sibling rendering. Development renders UnknownComponent; production renders nothing; both warn.

Package exports

Expose the manifest and advanced server primitives from ., and the named UI plugin from ./client. Keep both versions equal. Before publishing, run the plugin conformance kit from @adonia/core/testing; TypeScript alone does not prove hook ordering, isolation, or transaction behavior.

See the generated API reference and the reference plugins for complete examples.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close