Writing a Plugin
Build a RateLimiterPlugin from scratch. By the end you'll have a production-shaped plugin that:
- Reads its config from env vars through
EnvPlugin - Adds a
MiddlewareSpecthat enforces the limit - Exposes a typed verb on the framework via DI
- Self-advertises a
/metricsendpoint in the banner - Disposes cleanly on graceful shutdown
The same pattern scales to a tenancy plugin, audit-trail plugin, circuit-breaker plugin — anything that crosses the whole server.
1. Decide What You're Contributing
The plugin needs to:
| Contribution | Slot |
|---|---|
Apply a rate limit to every request to /api | middlewares |
Read configuration (MAX_PER_MINUTE) at startup | init |
Expose current bucket state via /_metrics/rate_limit | routes |
Hand callers a RateLimitChecker they can call manually | di |
| Print the metrics URL in the banner | bannerLines |
Five of the framework's contribution surfaces — covered without inventing anything new.
2. The Plugin Class
// lib/rate_limiter_plugin.dart
import 'dart:async';
import 'package:relic/relic.dart';
import 'package:vyuh_server/vyuh_server.dart';
class RateLimiterPlugin extends Plugin {
RateLimiterPlugin({
this.maxPerMinute = 1000,
this.path = '/api',
this.metricsPath = '/_metrics/rate_limit',
});
final int maxPerMinute;
final String path;
final String metricsPath;
late final _RateLimiter _limiter;
@override
String get name => 'kernel.rate_limiter';
@override
String? get description => 'Token-bucket rate limiter';
@override
Future<void> init(Object runtime) async {
_limiter = _RateLimiter(maxPerMinute: maxPerMinute);
}
@override
Future<void> dispose(Object runtime) async {
_limiter.shutdown();
}
// ─── Contribution slots ──────────────────────────────────────
@override
List<MiddlewareSpec> get middlewares => [
MiddlewareSpec(
path: path,
middleware: _rateLimitMiddleware(_limiter),
order: 800, // outer — runs before auth + handlers
),
];
@override
List<RouteSpec> get routes => [
RouteSpec(
method: 'GET',
path: metricsPath,
handler: (req) async => jsonResponse(
body: {'success': true, 'data': _limiter.stats()},
),
),
];
@override
List<DiRegistration> get di => [
DiRegistration.value<RateLimitChecker>(_limiter),
];
@override
List<String> bannerLines(String baseUrl) => [
' Rate-limit metrics: $baseUrl$metricsPath',
];
}3. The Middleware
Middleware _rateLimitMiddleware(_RateLimiter limiter) {
return (Handler inner) {
return (Request req) async {
final clientId = req.clientContext.ipAddress; // or req.actor.id
if (!limiter.tryAcquire(clientId)) {
return Response(
statusCode: 429,
headers: Headers({'retry-after': '60'}),
body: Body.fromString('rate limit exceeded'),
);
}
return inner(req);
};
};
}The middleware reads req.clientContext.ipAddress, populated by the framework's outermost clientContextMiddleware. No need to re-derive the IP from headers.
4. The Public DI Type
abstract interface class RateLimitChecker {
bool tryAcquire(String clientId);
Map<String, Object?> stats();
}The actual implementation lives behind the abstract — handlers reach for the interface:
final checker = vyuh.di.get<RateLimitChecker>();
if (!checker.tryAcquire(actorId)) {
throw TooManyRequests();
}This is the plugin's typed verb. The full framework gives you vyuh.db, vyuh.auth, etc.; your plugin gives callers vyuh.di.get<RateLimitChecker>(). A custom plugin can also publish a top-level extension method to keep call sites terse:
extension VyuhRateLimit on VyuhGlobalProxy {
RateLimitChecker get rateLimit => di.get<RateLimitChecker>();
}
// Usage:
vyuh.rateLimit.tryAcquire(actorId);5. Env-Driven Configuration
Define a typed config with a factory that reads from the env cascade:
class RateLimiterConfig {
RateLimiterConfig({required this.maxPerMinute});
factory RateLimiterConfig.fromEnv() => RateLimiterConfig(
maxPerMinute: int.parse(vyuh.env.read('RATE_LIMIT_PER_MIN') ?? '1000'),
);
final int maxPerMinute;
}Wire it into EnvPlugin(configs: [...]) at bootstrap so the typed config is registered before any other plugin's init():
EnvPlugin(
configs: [
TypedEnvConfig.factory(RateLimiterConfig.fromEnv),
],
);Then read it in init:
@override
Future<void> init(Object runtime) async {
final cfg = vyuh.env.get<RateLimiterConfig>();
_limiter = _RateLimiter(maxPerMinute: cfg.maxPerMinute);
}The plugin's constructor maxPerMinute becomes an override for tests or special hosts — env-derived for production.
6. Plug It In
final runtime = await VyuhServer.bootstrap(
name: 'saas-api',
plugins: [
EnvPlugin(
configs: [TypedEnvConfig.factory(RateLimiterConfig.fromEnv)],
),
PostgresDbPlugin(),
RateLimiterPlugin(), // reads RATE_LIMIT_PER_MIN via env
],
features: [appFeature, catalogFeature],
);The banner now prints:
saas-api listening on http://localhost:8080
Rate-limit metrics: http://localhost:8080/_metrics/rate_limit7. Tests
test('rate-limiter rejects after threshold', () async {
final runtime = await VyuhServer.bootstrap(
name: 'rate-limit-test',
plugins: [
RateLimiterPlugin(maxPerMinute: 5),
],
features: [
FeatureDescriptor(
name: 'echo',
routes: [
RouteSpec(
method: 'GET',
path: '/api/echo',
handler: (req) async => Response.ok(body: Body.fromString('ok')),
),
],
),
],
);
try {
final server = await runtime.start(port: 0, banner: false);
final port = server.port;
for (var i = 0; i < 5; i++) {
final res = await http.get(Uri.parse('http://localhost:$port/api/echo'));
expect(res.statusCode, 200);
}
final blocked = await http.get(Uri.parse('http://localhost:$port/api/echo'));
expect(blocked.statusCode, 429);
} finally {
await runtime.shutdown();
}
});What You Just Built
Look back at the plugin class. There's no boilerplate. No registration function. No "framework calls" — every contribution is data: a list of specs, a list of routes, a list of DI entries.
When you ship RateLimiterPlugin as a package, your users plug it in with one line in plugins:. The framework wires the middleware in the right place in the onion, exposes the metrics endpoint, prints the banner line, and tears it all down on SIGTERM.
This is the rhythm Vyuh Server is built for. Any cross-cutting concern in any domain (audit trails, idempotency keys, feature flags, tenancy, mTLS) follows the exact same shape.
Where to Go Next
- Plugins & Features — the contribution model
- Descriptors — when your plugin is capability-shaped instead of cross-cutting
- Architecture — the lifecycle that wires all this together