126 lines
4.3 KiB
TypeScript
126 lines
4.3 KiB
TypeScript
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<RateLimitPluginConfig> {
|
|
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<RateLimitPluginConfig>({
|
|
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<RateLimitPluginConfig>,
|
|
config: RateLimitPluginConfig,
|
|
state: RateLimitPluginState
|
|
): Promise<void> {
|
|
// (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,
|
|
});
|
|
}
|