Skip to content

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 MiddlewareSpec that enforces the limit
  • Exposes a typed verb on the framework via DI
  • Self-advertises a /metrics endpoint 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:

ContributionSlot
Apply a rate limit to every request to /apimiddlewares
Read configuration (MAX_PER_MINUTE) at startupinit
Expose current bucket state via /_metrics/rate_limitroutes
Hand callers a RateLimitChecker they can call manuallydi
Print the metrics URL in the bannerbannerLines

Five of the framework's contribution surfaces — covered without inventing anything new.

2. The Plugin Class

dart
// 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

dart
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

dart
abstract interface class RateLimitChecker {
  bool tryAcquire(String clientId);
  Map<String, Object?> stats();
}

The actual implementation lives behind the abstract — handlers reach for the interface:

dart
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:

dart
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:

dart
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():

dart
EnvPlugin(
  configs: [
    TypedEnvConfig.factory(RateLimiterConfig.fromEnv),
  ],
);

Then read it in init:

dart
@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

dart
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_limit

7. Tests

dart
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