import { definePlugin, type PluginContext, type PluginDescriptor, } from "@suaveplan/api-plugin"; import type { Clock } from "@suaveplan/types"; import { RATE_LIMIT_PLUGIN_CODES, RateLimitPluginError, } from "../error-codes/error-codes.js"; import { rateLimit } from "../middleware/rate-limit.js"; import { RateLimitPluginOptionsSchema } from "../schemas/options.js"; import { createDefaultStorage, createRateLimitService, type RateLimitService, } from "../services/rate-limit-service.js"; import "../services/registry-augmentation.js"; import type { RateLimitPluginConfig, RateLimitPluginOptions, } from "../types/types.js"; /** * Internal state owned by the plugin instance across the 4-phase * lifecycle. Captured as a closure variable rather than on * `PluginContext` because the registry's per-hook context is rebuilt * each call. */ interface RateLimitPluginState { service: RateLimitService | null; } /** * Factory — produces a typed `PluginDescriptor` for the rate-limit * plugin. * * Zod-validates the supplied options at the boundary so invalid input * fails fast with `RateLimitPluginError(INVALID_INPUT)` carrying the * `ZodError` as `cause`. The returned descriptor's `onInit` executes * the canonical 6-step lifecycle: * * 1. Resolve `app` + `clock` + `logger` from the host * `ServiceRegistry`. * 2. Build the `RateLimitStorage` (caller-supplied or the bundled * `MemoryStorage`). * 3. Construct the `RateLimitService` bound to the resolved config. * 4. Register `rateLimitService` in the `ServiceRegistry`. * 5. Install the global `rateLimit` middleware on `ctx.app` so every * request is rate-limited by default. * 6. Emit a single info log so operators see the plugin came online. */ export function createRateLimitPlugin( options: RateLimitPluginOptions ): PluginDescriptor { const parsed = RateLimitPluginOptionsSchema.safeParse(options); if (!parsed.success) { throw new RateLimitPluginError( RATE_LIMIT_PLUGIN_CODES.INVALID_INPUT, "createRateLimitPlugin received invalid options", { cause: parsed.error, context: { statusCode: 500 } } ); } const resolved = parsed.data; const state: RateLimitPluginState = { service: null }; return definePlugin({ name: "rate-limit", version: "0.1.0", description: "Rate-limit plugin for @suaveplan/api. Wraps @suaveplan/rate-limiter with per-route + per-tenant configuration, IP whitelist, parameterised path matching, Hono middleware, and X-RateLimit-* / Retry-After headers.", defaults: resolved, tags: ["rate-limit", "security"], onInit: async (ctx) => runOnInit(ctx, resolved, state), onClose: async () => { state.service = null; }, }); } async function runOnInit( ctx: PluginContext, config: RateLimitPluginConfig, state: RateLimitPluginState ): Promise { // (1) Resolve required services from the host ServiceRegistry. const app = ctx.getService("app"); if (app === undefined) { throw new RateLimitPluginError( RATE_LIMIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE, "rate-limit plugin requires the `app` service in ServiceRegistry", { context: { missing: ["app"], statusCode: 500 } } ); } const clock: Clock = ctx.getService("clock") ?? (() => Date.now()); const logger = config.logger ?? ctx.logger; // (2) Build storage. const storage = config.storage ?? createDefaultStorage({ clock }); // (3) Construct the service. const service = createRateLimitService({ config, storage, clock, logger, }); state.service = service; // (4) Register the service slot. ctx.registerService("rateLimitService", service); // (5) Install the global middleware. app.use("*", rateLimit({ service })); // (6) Log readiness. logger.info("rate-limit plugin: ready", { defaultLimit: config.defaultLimit, defaultWindowMs: config.defaultWindowMs, routes: config.routes.length, tenants: config.tenants.length, ipWhitelist: config.ipWhitelist.length, headerStyle: config.headerStyle, }); }