Plugin Authoring Guide
This guide walks you through creating, testing, and publishing a Guren plugin.
Plugin Authoring Guide
This guide walks you through creating, testing, and publishing a Guren plugin.
What Is a Plugin?
A Guren plugin is an npm package that exports a ServiceProvider subclass. When users add your provider to their createApp({ providers }) array, the framework calls your register() and boot() hooks during application startup.
For the full contract and rules, see Plugin Contract.
Step 1: Create a New Package
mkdir guren-plugin-analytics
cd guren-plugin-analytics
bun init
Set up your package.json:
{
"name": "guren-plugin-analytics",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"gurenPlugin": {
"compatibility": ">=1.0.0"
},
"peerDependencies": {
"@guren/core": ">=1.0.0"
},
"devDependencies": {
"@guren/core": "^1.0.0",
"@guren/testing": "^1.0.0",
"typescript": "^5.0.0"
}
}
Key points:
@guren/coreis a peer dependency -- the host application provides it.@guren/coreand@guren/testingare dev dependencies for building and testing.- The
gurenPlugin.compatibilityfield declares which Guren versions your plugin supports.
Step 2: Define the Plugin
Use the definePlugin() helper from @guren/core. It captures your configuration in a closure and returns a factory that produces an independent provider class per call, so the same plugin can be registered twice with different configurations:
// src/plugin.ts
import { definePlugin } from '@guren/core'
export interface AnalyticsConfig {
apiKey: string
endpoint?: string
batchSize?: number
}
export class AnalyticsClient {
constructor(private config: AnalyticsConfig) {}
track(event: string, properties?: Record<string, unknown>): void {
// Send analytics event to the configured endpoint
console.log(`[Analytics] ${event}`, properties)
}
}
export const analyticsPlugin = definePlugin<AnalyticsConfig>({
name: 'analytics',
register(container, config) {
container.singleton('analytics', () => new AnalyticsClient(config))
},
boot(container) {
// Subscribe to framework events after all providers are registered
if (container.has('events')) {
const events = container.make('events')
const analytics = container.make<AnalyticsClient>('analytics')
events.on('request.completed', (data: Record<string, unknown>) => {
analytics.track('page_view', data)
})
}
},
})
Plugins that are expensive to initialize can pass deferred: true together with provides: ['analytics'] so the provider only loads when one of its services is first resolved.
If you need lifecycle behavior beyond what definePlugin() covers, you can still export a ServiceProvider subclass directly — the class-based contract is unchanged. Avoid storing configuration on a static class property, though: statics are shared, so registering the plugin twice would silently overwrite the first configuration.
Step 3: Export the Plugin
// src/index.ts
export { analyticsPlugin, AnalyticsClient } from './plugin'
export type { AnalyticsConfig } from './plugin'
Step 4: Add Plugin Metadata
Your package.json must include the gurenPlugin field:
{
"gurenPlugin": {
"compatibility": ">=1.0.0",
"provider": "AnalyticsServiceProvider",
"env": [
{ "key": "ANALYTICS_API_KEY", "comment": "Analytics service API key" }
],
"publishes": [
{ "from": "stubs/analytics.ts", "to": "config/analytics.ts" }
]
}
}
| Field | Purpose |
|---|---|
compatibility |
Semver range of Guren versions your plugin supports. Verified by bunx guren plugin at install time and by bunx guren doctor. |
provider |
Named class export for bunx guren plugin to register in createApp({ providers }). Omit for definePlugin() factories (registered manually). |
env |
Env keys appended to the app's .env.example (and .env when present) at install time. |
publishes |
Files copied from your package into the app (config/, db/migrations/, or resources/ only). Existing files are never overwritten without --force. |
The manifest is pure data — the CLI never executes plugin code during installation.
Optional: Contribute CLI Commands
Plugins can add commands to the guren CLI by declaring them in the manifest:
{
"gurenPlugin": {
"commands": {
"entry": "./dist/commands.js",
"names": ["analytics:flush"]
}
}
}
The entry module default-exports a record of citty command definitions keyed by command name:
// src/commands.ts
import { defineCommand } from 'citty'
export default {
'analytics:flush': defineCommand({
meta: { name: 'analytics:flush', description: 'Flush queued analytics events' },
async run() {
// ...
},
}),
}
Once the plugin is installed in an app, bunx guren analytics:flush runs the command and bunx guren --help lists it. Names must contain a : namespace, built-in command names always win, and a name declared by two plugins is dropped for both with a warning. The entry module is imported only when one of the declared commands is invoked (or renders its own --help) — never for the root listing.
Step 5: Write Tests
Use createPluginTestApp and assertPluginRegisters from @guren/testing:
// src/plugin.test.ts
import { describe, test, expect } from 'bun:test'
import { createPluginTestApp, assertPluginRegisters } from '@guren/testing'
import { analyticsPlugin, AnalyticsClient } from './plugin'
describe('analyticsPlugin', () => {
test('should register the analytics service', async () => {
const app = await createPluginTestApp([analyticsPlugin({ apiKey: 'test-key' })])
// Verify the service is bound
assertPluginRegisters(app, ['analytics'])
})
test('should resolve an AnalyticsClient instance', async () => {
const app = await createPluginTestApp([analyticsPlugin({ apiKey: 'test-key' })])
const client = app.container.make<AnalyticsClient>('analytics')
expect(client).toBeInstanceOf(AnalyticsClient)
})
test('should register as a singleton', async () => {
const app = await createPluginTestApp([analyticsPlugin({ apiKey: 'test-key' })])
const first = app.container.make<AnalyticsClient>('analytics')
const second = app.container.make<AnalyticsClient>('analytics')
expect(first).toBe(second)
})
})
Run the tests:
bun test src/plugin.test.ts
Step 6: Build
Add a build script using tsup:
{
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"test": "bun test"
},
"devDependencies": {
"tsup": "^8.0.0"
}
}
Step 7: Test Locally Before Publishing
Link the plugin into a real Guren app to verify it end to end before publishing:
# from your app's directory
bun add file:../guren-plugin-analytics
bunx guren plugin guren-plugin-analytics
bun add file: (and the link:/workspace: protocols) install the package as symlinks back to your plugin's source directory instead of copying it. If your plugin's package.json still has its own node_modules installed — from adding @guren/core as a devDependency in Step 1 — the app can end up loading two separate copies of @guren/core: one from its own install, one through the plugin's. This shows up as duplicate-module warnings at runtime, or a TypeScript error like Property 'bindings' is protected but type 'Container' is not a class derived from 'Container' at compile time.
If you hit this, delete node_modules inside your plugin's package directory before linking it into the app — the app's own @guren/core install then satisfies the plugin's peerDependencies with nothing left to shadow it. A published plugin never ships its node_modules, so this only affects local testing before publishing.
Step 8: Publish
bun run build
npm publish
Installing Plugins
Any plugin — official (@guren/plugin-*) or community (guren-plugin-*) — can be installed via the CLI:
bunx guren plugin @guren/plugin-vercel
The plugin command installs the package with bun add when missing (pass --no-install to skip), verifies the plugin's declared Guren compatibility (--ignore-compatibility to register anyway), adds the provider import, registers it in createApp({ providers }), and applies any env and publishes entries from the plugin's gurenPlugin manifest. --force overwrites already-published files.
Note: Automatic registration currently supports class-based provider exports only. Plugins built with
definePlugin()export a factory that must be called with its configuration, so register them manually increateApp({ providers })as shown below.
Usage in a Guren Application
Once published, users install and register the plugin:
bun add guren-plugin-analytics
// src/app.ts
import { createApp } from '@guren/core'
import { analyticsPlugin } from 'guren-plugin-analytics'
import { registerWebRoutes } from '@/routes/web'
export const app = createApp({
routes: registerWebRoutes,
providers: [
analyticsPlugin({
apiKey: process.env.ANALYTICS_API_KEY!,
endpoint: 'https://analytics.example.com',
}),
],
})
Complete Example: Request Logger Plugin
A minimal plugin that logs every incoming request:
// src/RequestLoggerProvider.ts
import { ServiceProvider } from '@guren/core'
import type { Hono, MiddlewareHandler } from 'hono'
export class RequestLoggerProvider extends ServiceProvider {
register(): void {
this.container.singleton('request-logger', () => {
return {
requests: [] as Array<{ method: string; path: string; timestamp: number }>,
}
})
}
boot(): void {
const hono = this.container.make<Hono>('hono')
const logger = this.container.make<{ requests: Array<{ method: string; path: string; timestamp: number }> }>('request-logger')
const middleware: MiddlewareHandler = async (c, next) => {
logger.requests.push({
method: c.req.method,
path: c.req.path,
timestamp: Date.now(),
})
await next()
}
hono.use('*', middleware)
}
}
Test it:
import { describe, test, expect } from 'bun:test'
import { createPluginTestApp, assertPluginRegisters } from '@guren/testing'
import { RequestLoggerProvider } from './RequestLoggerProvider'
describe('RequestLoggerProvider', () => {
test('should register request-logger service', async () => {
const app = await createPluginTestApp([RequestLoggerProvider])
assertPluginRegisters(app, ['request-logger'])
})
test('should initialize with empty request log', async () => {
const app = await createPluginTestApp([RequestLoggerProvider])
const logger = app.container.make<{ requests: unknown[] }>('request-logger')
expect(logger.requests).toHaveLength(0)
})
})
Tips
- Keep
register()synchronous when possible. Both hooks support async, but synchronous registration is faster. - Use deferred providers for heavy dependencies. If your plugin loads a large SDK, mark the provider as deferred so it only initializes when needed.
- Depend on the container, not imports. Resolve services via
this.container.make()rather than importing framework internals directly. - Test against multiple Guren versions. Use a CI matrix to run your test suite against the minimum and latest supported versions.
- Document the services you register. Users need to know what container keys your plugin provides so they can resolve them in their own code.