Custom Plugin Development
This guide shows you how to build custom plugins for the Setu-TS framework.
Creating Your First Plugin
Step 1: Define the Plugin
// my-plugin.ts
import { CAPABILITIES } from '@setu-ts/common';
import type { IPlugin, IPluginContext } from '@setu-ts/common';
export interface MyPluginOptions {
greeting?: string;
enabled?: boolean;
}
export function MyPlugin(options: MyPluginOptions = {}): IPlugin {
const config = {
greeting: options.greeting ?? 'Hello',
enabled: options.enabled ?? true,
};
return {
name: 'my-plugin',
version: '1.0.0',
dependencies: [CAPABILITIES.RUNTIME], // Requires RuntimePlugin
provides: ['my-service'],
async register(ctx: IPluginContext) {
// Register a service
ctx.services.register('my-service', {
greet: (name: string) => `${config.greeting}, ${name}!`,
});
// Add middleware, unless the application turned it off
if (config.enabled) {
ctx.middleware.add(async (ctx, next) => {
await next();
ctx.response.header('x-greeting', config.greeting);
});
}
// Register a route
ctx.router.get('/greet/:name', async (ctx) => {
const service = ctx.services.get<{ greet: (name: string) => string }>('my-service');
return ctx.response.json({ message: service.greet(ctx.params.name) });
});
},
};
}
Step 2: Use the Plugin
import type { IPlugin, IPluginContext } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
function MyPlugin(options: { greeting?: string }): IPlugin {
const greeting = options.greeting ?? 'Hello';
return {
name: 'my-plugin',
version: '1.0.0',
async register(ctx: IPluginContext) {
ctx.services.register('my-service', { greet: (name: string) => `${greeting}, ${name}!` });
},
};
}
const app = createApplication();
app.register(RuntimePlugin());
app.register(MyPlugin({ greeting: 'Bonjour' }));
await app.start({ port: 3000 });
Plugin Structure
Plugin Factory Pattern
Creating plugins via factory functions allows configuration and encapsulation:
export function CachePlugin(options: CachePluginOptions): IPlugin {
const config = { ...defaultConfig, ...options };
return {
name: 'cache',
version: '1.0.0',
optionalDependencies: [CAPABILITIES.LOGGER],
provides: [CAPABILITIES.CACHE],
async register(ctx) {
const cache = createCache(config);
ctx.services.register(CAPABILITIES.CACHE, cache);
ctx.lifecycle.onClose(() => {
cache.close();
});
},
};
}
Service Registration
// Register a singleton. Registering a token twice throws.
ctx.services.register('my-service', new MyService());
// Register a factory (constructed on the first lookup)
ctx.services.registerFactory('my-lazy-service', () => new MyService());
// Replace a token another plugin already registered
ctx.services.register('my-service', new MyService(), { override: true });
// Add one of several providers; consumers read them all with getAll()
ctx.services.register('my-extensions', new MyService(), { multi: true });
const extensions = ctx.services.getAll<MyService>('my-extensions');
Middleware
import { CAPABILITIES } from '@setu-ts/common';
import type { IRuntimeServices, MiddlewareFunction } from '@setu-ts/common';
const myMiddleware: MiddlewareFunction = async (ctx, next) => {
// Use the runtime's monotonic clock for durations, not host performance.now().
const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
const start = runtime.hrtime();
await next();
const duration = runtime.hrtime() - start;
// Use wall-clock time from runtime for timestamps, not host Date.now().
console.log('Request completed', { duration, timestamp: runtime.now() });
};
// Add to pipeline
ctx.middleware.add(myMiddleware);
// Add with priority
ctx.middleware.add(myMiddleware, { priority: 25 });
// Add to a specific route
ctx.router.get('/reports', {
middleware: [myMiddleware],
handler: async (requestCtx) => requestCtx.response.json({ ok: true }),
});
Routes
import type { RouteHandler } from '@setu-ts/common';
// GET route
const getHandler: RouteHandler = async (ctx) => ctx.response.json({ ok: true });
ctx.router.get('/path', getHandler);
// POST route
ctx.router.post('/path', getHandler);
// PUT, PATCH, DELETE, HEAD, OPTIONS
ctx.router.put('/path', getHandler);
ctx.router.patch('/path', getHandler);
ctx.router.delete('/path', getHandler);
ctx.router.head('/path', getHandler);
ctx.router.options('/path', getHandler);
// Route group
ctx.router.group('/api', (group) => {
group.get('/users', getHandler);
group.post('/users', getHandler);
});
Health Checks
ctx.health.register('my-check', async () => {
return {
status: 'up',
data: { timestamp: ctx.runtime.now() },
};
});
Metrics
import { CAPABILITIES } from '@setu-ts/common';
import type { ICounter, IGauge, IHistogram, IMetricsService } from '@setu-ts/common';
// Resolve IMetricsService through the capability token
const metrics = ctx.services.get<IMetricsService>(CAPABILITIES.METRICS);
// Create a custom counter (counter() returns ICounter, not void)
const requestCounter: ICounter = metrics.counter('my_requests_total', {
help: 'Total requests handled by my plugin',
labels: ['method', 'path'],
});
// Explicitly increment the counter — a custom counter is NOT automatically
// observed by built-in HTTP collection. You must call inc() yourself.
ctx.middleware.add(async (ctx, next) => {
await next();
requestCounter.inc(1, {
method: ctx.request.method,
path: ctx.request.path,
});
});
// Gauge for tracking current connections
const activeConnections: IGauge = metrics.gauge('active_connections', {
help: 'Current active connections',
});
// Histogram for request durations
const requestDuration: IHistogram = metrics.histogram('request_duration_seconds', {
help: 'Request duration in seconds',
buckets: [0.1, 0.5, 1, 2.5, 5],
});
Lifecycle Hooks
// Initialization (before pipeline compilation)
ctx.lifecycle.onInit(() => {
console.log('Initializing plugin...');
});
// Bootstrap (after pipeline compilation, before accepting requests)
ctx.lifecycle.onBootstrap(() => {
console.log('Plugin ready!');
});
// Per-request hooks
ctx.lifecycle.onRequest((ctx) => {
console.log('Request started:', ctx.request.url);
});
ctx.lifecycle.onResponse((ctx) => {
console.log('Response sent:', ctx.response.snapshot().status);
});
ctx.lifecycle.onError((error, ctx) => {
console.error('Request error:', error);
});
// Shutdown hooks
ctx.lifecycle.onStopping(() => {
console.log('Stopping - deregister from external traffic sources');
});
ctx.lifecycle.onShutdown(() => {
console.log('Shutdown - kernel has drained requests and closed the socket');
});
ctx.lifecycle.onClose(() => {
console.log('Close - releasing resources');
});
CLI Commands
ctx.cli.register('my-plugin:greet', async (args) => {
console.log('Command executed with args:', args);
});
OpenAPI Contributions
// Add a schema
ctx.openapi.addSchema('User', {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
});
Environment Declaration
ctx.environment.validate({
PORT: {
type: 'number',
default: 3000,
},
NODE_ENV: {
type: 'string',
default: 'development',
},
API_KEY: {
type: 'string',
required: true,
},
});
Configuration
// Read a configuration key. `ctx.config` is present only when ConfigPlugin is
// registered, and keys are flat names as they appear in the environment.
const greeting = ctx.config?.get<string>('MY_PLUGIN_GREETING', { default: 'Hello' }) ?? 'Hello';
Using Runtime Services
// Platform detection
const platform = ctx.runtime.platform();
if (platform === 'cloudflare-workers') {
// Workers-specific logic
}
// Environment variables
const env = ctx.runtime.env;
const apiKey = env.API_KEY;
// UUID generation
const id = ctx.runtime.uuid();
// Random bytes
const bytes = ctx.runtime.randomBytes(32);
// Time
const now = ctx.runtime.now(); // Wall clock (ms since epoch)
const hrtime = ctx.runtime.hrtime(); // Monotonic (ms since arbitrary origin)
// Timers
const timeoutId = ctx.runtime.setTimeout(() => {}, 1000);
ctx.runtime.clearTimeout(timeoutId);
// File system (may be undefined on Workers)
if (ctx.runtime.fs) {
const fileContent = await ctx.runtime.fs.readFile('file.txt');
}
// Web Crypto, available on every runtime
const importedKey = await ctx.runtime.subtle.importKey(
'raw',
new Uint8Array(32),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
Dependencies
Hard Dependencies
export function MyPlugin(): IPlugin {
return {
name: 'my-plugin',
version: '1.0.0',
dependencies: [CAPABILITIES.RUNTIME, CAPABILITIES.LOGGER], // Will fail if missing
async register(ctx) {
// ctx.logger is guaranteed when CAPABILITIES.LOGGER is in dependencies.
ctx.logger?.info('Plugin registered');
},
};
}
Optional Dependencies
export function MyPlugin(): IPlugin {
return {
name: 'my-plugin',
version: '1.0.0',
optionalDependencies: [CAPABILITIES.CACHE], // Works without it
async register(ctx) {
if (ctx.services.has(CAPABILITIES.CACHE)) {
const cache = ctx.services.get(CAPABILITIES.CACHE);
// Use cache
} else {
// Fallback behavior
}
},
};
}
Soft Dependencies (Consumes)
export function MyPlugin(): IPlugin {
return {
name: 'my-plugin',
version: '1.0.0',
consumes: [CAPABILITIES.METRICS], // Warning if missing, doesn't fail
async register(ctx) {
// Plugin works but logs warning if metrics not available
},
};
}
Testing Custom Plugins
Unit Tests
import type { IPlugin } from '@setu-ts/common';
import { describe, it } from '@std/testing/bdd';
import { expect } from '@std/expect';
// Self-contained plugin for testing (no relative import)
function MyPlugin(): IPlugin {
return {
name: 'my-plugin',
version: '1.0.0',
register(ctx) {
ctx.services.register('my-service', { greet: (name: string) => `Hello, ${name}!` });
},
};
}
describe('MyPlugin', () => {
it('has correct name and version', () => {
const plugin = MyPlugin();
expect(plugin.name).toBe('my-plugin');
expect(plugin.version).toBe('1.0.0');
});
it('registers the service', async () => {
const plugin = MyPlugin();
const mockServices = {
registered: new Map<string, unknown>(),
register: (token: string, service: unknown) => {
mockServices.registered.set(token, service);
},
get: (token: string) => mockServices.registered.get(token),
has: (token: string) => mockServices.registered.has(token),
};
await plugin.register({
services: mockServices as never,
runtime: {} as never,
// ... other context properties
} as never);
expect(mockServices.has('my-service')).toBe(true);
});
});
Integration Tests
import type { IPlugin } from '@setu-ts/common';
import { RuntimePlugin } from '@setu-ts/runtime';
import { createTestApp, inject } from '@setu-ts/testing';
import { describe, it } from '@std/testing/bdd';
import { expect } from '@std/expect';
// Self-contained plugin for testing (no relative import)
function MyPlugin(): IPlugin {
return {
name: 'my-plugin',
version: '1.0.0',
async register(ctx) {
ctx.router.get('/greet/:name', (ctx) => {
return ctx.response.json({ message: `Hello, ${ctx.params.name}!` });
});
},
};
}
describe('MyPlugin integration', () => {
it('handles GET /greet/:name', async () => {
// createTestApp starts the application, so every plugin goes in this list:
// registering one after start() throws.
const app = await createTestApp({
plugins: [RuntimePlugin(), MyPlugin()],
});
const response = await inject(app, {
method: 'GET',
url: '/greet/World',
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body).toEqual({ message: 'Hello, World!' });
});
});
Best Practices
1. Keep Plugins Focused
Each plugin should do one thing well. Split large plugins into smaller, composable units.
2. Use Capability Tokens
Always use capability tokens from @setu-ts/common or create your own with
createCapabilityToken().
3. Handle Missing Dependencies Gracefully
Check for optional dependencies before using them.
4. Clean Up Resources
Always register cleanup hooks for resources you allocate.
5. Document Your Plugin
Provide clear documentation including:
- What the plugin does
- Required and optional dependencies
- Configuration options
- Usage examples
6. Test Thoroughly
Test your plugin:
- In isolation (unit tests)
- In integration (with other plugins)
- With various configurations
7. Version Your Plugins
Follow semantic versioning and update the version field when making changes.
8. Error Handling
Provide clear, actionable error messages when something goes wrong.
Publishing Your Plugin
Package Structure
my-plugin/
├── package.json
├── tsconfig.json
├── README.md
├── src/
│ ├── index.ts
│ └── plugin.ts
└── test/
└── plugin.test.ts
package.json
{
"name": "@acme/my-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": { "build": "tsc" },
"peerDependencies": {
"@setu-ts/common": "^0.11.0"
},
"devDependencies": {
"@setu-ts/common": "^0.11.0",
"typescript": "^6.0.3"
}
}
Declare every @setu-ts package the plugin imports at runtime as a peer at the application’s range,
and also as a development dependency to build against it. The example plugin imports only
@setu-ts/common at runtime; change its range to match the application consuming it.
The application must hold one copy of each framework package. The exceptions handler checks
rawError instanceof HttpError, common stores request state under module-private symbols, and React
Router’s contextKeyFor() keys match by identity. A private copy can turn an intended HTTP error
into a 500 or read a slot the application never wrote. First-party packages use dependencies because
they release in lockstep.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"declaration": true,
"rootDir": "src",
"outDir": "dist",
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"]
}
npm run build emits JavaScript and declarations into dist; relative source imports such as
./plugin.ts become JavaScript imports in the output. Publish the built package to npm.
README.md
# @acme/my-plugin
A custom plugin for Setu-TS.
## Installation
```bash
npm install @acme/my-plugin
```
## Usage
```typescript
import { MyPlugin } from '@acme/my-plugin';
app.register(MyPlugin({ greeting: 'Bonjour' }));
```
## Options
| Option | Type | Default | Description |
| -------- | ------- | --------- | ---------------------------- |
| greeting | string | `'Hello'` | The greeting word |
| enabled | boolean | `true` | Adds the `x-greeting` header |
Sharing a plugin inside one workspace
In a workspace created with setu new <name> --workspace, run setu generate library <name>. It
creates libs/<name>/, consumed as TypeScript source with no build step. The generated library
manifest starts with no framework dependency: add every @setu-ts package it imports to that
manifest at the members’ range (dependencies in package.json, or imports in deno.json).
Next Steps
- Plugin Architecture - Deep dive into the plugin system
- Examples - See real-world applications