Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
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,
});
}