first commit
This commit is contained in:
133
docs/api-stack/api/api-plugin-rate-limit/README.md
Normal file
133
docs/api-stack/api/api-plugin-rate-limit/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# @suaveplan/api-plugin-rate-limit - Rate-limit plugin for @suaveplan/api. Wraps @suaveplan/rate-limiter as a definePlugin descriptor: per-route + per-tenant configuration, parameterised path matcher, IP whitelist, tenant multiplier applied to numeric limit, Hono middleware factory, 429 + Retry-After + X-RateLimit-* / RateLimit-* headers, OTel counters, RFC 7807 Problem+JSON body. Registers `rateLimitService` in the host ServiceRegistry.
|
||||
|
||||
Rate-limit plugin for `@suaveplan/api`. Wraps `@suaveplan/rate-limiter`
|
||||
as a `definePlugin` descriptor exposing per-route + per-tenant
|
||||
configuration, parameterised path matching, IP whitelist, applied
|
||||
tenant multiplier, Hono middleware, `X-RateLimit-*` / `RateLimit-*` /
|
||||
`Retry-After` headers, RFC 7807 Problem+JSON 429 body, and OTel
|
||||
counters / histogram. Registers a typed `rateLimitService` slot in the
|
||||
host `ServiceRegistry` so sibling plugins can run manual checks.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Provides the stable, namespaced error-code registry every failure path in `@suaveplan/api-plugin-rate-limit` reports th…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Provides the stable, namespaced error-code registry every failure path in `@suaveplan/api-plugin-rate-limit` reports th…
|
||||
- [headers](./src/headers/headers.md) — Builds the HTTP response headers and Problem+JSON body the rate-limit middleware writes onto every response (allowed AN…
|
||||
- [headers](./src/headers/headers.md) — Builds the HTTP response headers and Problem+JSON body the rate-limit middleware writes onto every response (allowed AN…
|
||||
- [context-augmentation](./src/middleware/context-augmentation.md) — Augments Hono's `ContextVariableMap` interface so handlers calling `c.get("rateLimit")` get a typed `RateLimitDecision`…
|
||||
- [rate-limit](./src/middleware/rate-limit.md) — Constructs a Hono `MiddlewareHandler` that consults a `RateLimitService` on every request, emits the configured `X-Rate…
|
||||
- [otel](./src/otel/instruments.md) — Builds (and caches) the bundle of OpenTelemetry instruments the rate-limit plugin emits on every check. Operators wirin…
|
||||
- [instruments](./src/otel/instruments.md) — Builds (and caches) the bundle of OpenTelemetry instruments the rate-limit plugin emits on every check. Operators wirin…
|
||||
- [path-matcher](./src/path-matcher/path-matcher.md) — Compiles a Hono-style path pattern string into a regular expression
|
||||
- [path-matcher](./src/path-matcher/path-matcher.md) — Compiles a Hono-style path pattern string into a regular expression
|
||||
- [plugin](./src/plugin/plugin.md) — Factory that returns a typed `PluginDescriptor` for the rate-limit plugin, ready to be passed to `@suaveplan/api`'s plu…
|
||||
- [plugin](./src/plugin/plugin.md) — Factory that returns a typed `PluginDescriptor` for the rate-limit plugin, ready to be passed to `@suaveplan/api`'s plu…
|
||||
- [schemas](./src/schemas/options.md) — Validates the caller-supplied `RateLimitPluginOptions` at the boundary and fills in defaults for every optional field,…
|
||||
- [options](./src/schemas/options.md) — Validates the caller-supplied `RateLimitPluginOptions` at the boundary and fills in defaults for every optional field,…
|
||||
- [rate-limit-service](./src/services/rate-limit-service.md) — Wraps the universal `@suaveplan/rate-limiter` core in the plugin's policy layer: IP whitelist, tenant bypass, tenant mu…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — Augments the `ServiceRegistry` interface owned by `@suaveplan/api-plugin/service-registry` so consumers see the `rateLi…
|
||||
- [types](./src/types/types.md) — Defines the public type surface every other module in the package references — the caller-facing `RateLimitPluginOption…
|
||||
- [types](./src/types/types.md) — Defines the public type surface every other module in the package references — the caller-facing `RateLimitPluginOption…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-plugin-rate-limit
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createApi } from "@suaveplan/api";
|
||||
import { createRateLimitPlugin } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const api = createApi();
|
||||
api.use(
|
||||
createRateLimitPlugin({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
routes: [
|
||||
{
|
||||
path: "/api/login",
|
||||
method: "POST",
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
id: "login",
|
||||
},
|
||||
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
|
||||
],
|
||||
tenants: [
|
||||
{ tenantId: "enterprise", multiplier: 10 },
|
||||
{ tenantId: "internal", bypass: true },
|
||||
],
|
||||
ipWhitelist: ["127.0.0.1"],
|
||||
headerStyle: "both",
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Every request flowing through the host Hono app is now rate-limited.
|
||||
Responses carry both legacy `X-RateLimit-Limit` / `X-RateLimit-Remaining`
|
||||
/ `X-RateLimit-Reset` and IETF `RateLimit-Limit` / `RateLimit-Remaining`
|
||||
/ `RateLimit-Reset` headers. Denied requests return `429` with
|
||||
`Content-Type: application/problem+json` and a `Retry-After` header.
|
||||
|
||||
## Manual `service.check(...)` from sibling plugins
|
||||
|
||||
```ts
|
||||
import {
|
||||
rateLimitExceeded,
|
||||
type RateLimitService,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const service = ctx.getService("rateLimitService") as RateLimitService;
|
||||
const decision = await service.check({
|
||||
ip,
|
||||
tenantId,
|
||||
path: "/api/billing/invoice",
|
||||
method: "POST",
|
||||
});
|
||||
if (!decision.allowed) throw rateLimitExceeded(decision);
|
||||
```
|
||||
|
||||
## The 3-way plug-and-play contract
|
||||
|
||||
This package follows the same contract every `@suaveplan/api-plugin-*`
|
||||
package follows: it owns its boundary Zod schemas
|
||||
(`RateLimitPluginOptionsSchema`), registers a typed slot in the host
|
||||
`ServiceRegistry` (`rateLimitService`), and installs a global Hono
|
||||
middleware via `app.use("*", rateLimit({ service }))` from its
|
||||
canonical 6-step `onInit` lifecycle (resolve services → build storage
|
||||
→ construct service → register slot → install middleware → log
|
||||
readiness).
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) —
|
||||
`RATE_LIMIT_PLUGIN_CODES` registry + `RateLimitPluginError`.
|
||||
- [`src/types/types.md`](src/types/types.md) — public type surface.
|
||||
- [`src/schemas/options.md`](src/schemas/options.md) — Zod schema for
|
||||
caller-supplied options.
|
||||
- [`src/path-matcher/path-matcher.md`](src/path-matcher/path-matcher.md)
|
||||
— Hono-compatible path-pattern compilation + matching.
|
||||
- [`src/headers/headers.md`](src/headers/headers.md) — header bag +
|
||||
Problem+JSON body construction.
|
||||
- [`src/otel/instruments.md`](src/otel/instruments.md) — OpenTelemetry
|
||||
instruments bundle.
|
||||
- [`src/services/rate-limit-service.md`](src/services/rate-limit-service.md)
|
||||
— `RateLimitService` factory.
|
||||
- [`src/services/registry-augmentation.md`](src/services/registry-augmentation.md)
|
||||
— `ServiceRegistry` module augmentation.
|
||||
- [`src/middleware/rate-limit.md`](src/middleware/rate-limit.md) —
|
||||
Hono middleware factory.
|
||||
- [`src/middleware/context-augmentation.md`](src/middleware/context-augmentation.md)
|
||||
— Hono `ContextVariableMap` augmentation.
|
||||
- [`src/plugin/plugin.md`](src/plugin/plugin.md) —
|
||||
`createRateLimitPlugin` factory.
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` — initial ship. Surface is final for the 0.1.x line.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# `error-codes` — rate-limit plugin error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the stable, namespaced error-code registry every failure path
|
||||
in `@suaveplan/api-plugin-rate-limit` reports through. The codes plug
|
||||
into the `@suaveplan/error` ecosystem so consumers can pattern-match
|
||||
errors programmatically, the host's error-handler middleware can
|
||||
translate them into structured HTTP responses, and OpenTelemetry can
|
||||
tag span events with the code attribute.
|
||||
|
||||
The package ships a single typed exception class, `RateLimitPluginError`,
|
||||
that carries one of five codes plus the standard `SuaveplanError`
|
||||
metadata (`cause`, `context`, `messageKey`, span-event adapter,
|
||||
redaction-aware serialisation).
|
||||
|
||||
## Features
|
||||
|
||||
- Five stable codes: `INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`,
|
||||
`LIMIT_EXCEEDED`, `STORAGE_FAILURE`, `INVALID_PATH_PATTERN`.
|
||||
- Public constructor — middleware and callers may throw directly
|
||||
without inheriting protected base-class constructor visibility.
|
||||
- `RateLimitPluginError extends SuaveplanError<Code>` so `instanceof`
|
||||
narrows the `code` discriminant for downstream branching.
|
||||
- Codes registered through `defineCodes("RATE_LIMIT_PLUGIN", …)` so
|
||||
the OpenSpec conformance gate finds every code in the catalogue.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
RATE_LIMIT_PLUGIN_CODES,
|
||||
RateLimitPluginError,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
if (!options.defaultLimit) {
|
||||
throw new RateLimitPluginError(
|
||||
RATE_LIMIT_PLUGIN_CODES.INVALID_INPUT,
|
||||
"defaultLimit is required",
|
||||
{ context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
RATE_LIMIT_PLUGIN_CODES,
|
||||
RateLimitPluginError,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
try {
|
||||
await service.check({ ip, tenantId, path, method });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof RateLimitPluginError &&
|
||||
err.code === RATE_LIMIT_PLUGIN_CODES.STORAGE_FAILURE
|
||||
) {
|
||||
metrics.add("rate_limit.storage_failures", 1);
|
||||
// Fail open — allow the request rather than denying it during
|
||||
// a transient storage outage.
|
||||
return next();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RATE_LIMIT_PLUGIN_CODES` — `Readonly<Record<string, string>>` produced
|
||||
by `defineCodes`. Keys: `INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`,
|
||||
`LIMIT_EXCEEDED`, `STORAGE_FAILURE`, `INVALID_PATH_PATTERN`. Values
|
||||
are the namespaced code strings (e.g. `"RATE_LIMIT_PLUGIN.INVALID_INPUT"`).
|
||||
- `RateLimitPluginErrorCode` — union of every code value.
|
||||
- `class RateLimitPluginError extends SuaveplanError<RateLimitPluginErrorCode>` —
|
||||
carries `code`, `rateLimitCode` (alias), `cause`, `context`. Constructor:
|
||||
`new RateLimitPluginError(code, message, options?)` where `options`
|
||||
matches `SuaveplanErrorOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes are deliberately namespaced (`RATE_LIMIT_PLUGIN.*`) so the
|
||||
registry survives package renaming or merging without collisions.
|
||||
- `LIMIT_EXCEEDED` carries `statusCode: 429`, `retryAfter`, `limit`,
|
||||
`remaining` on its context so the host's error-handler can render
|
||||
the headers + Problem+JSON body without re-deriving them from the
|
||||
decision.
|
||||
- `STORAGE_FAILURE` is raised by `RateLimitService.check` whenever the
|
||||
injected storage adapter rejects; the caller may decide to fail
|
||||
open or closed.
|
||||
- Adding a new code requires bumping the version, appending to the
|
||||
`defineCodes` array, and wiring it into a production path — the
|
||||
OpenSpec conformance gate will refuse to fold the spec otherwise.
|
||||
@@ -0,0 +1,94 @@
|
||||
# `headers` — HTTP header + RFC 7807 body construction for rate-limit responses
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the HTTP response headers and Problem+JSON body the rate-limit
|
||||
middleware writes onto every response (allowed AND denied). Centralises
|
||||
the legacy `X-RateLimit-*` triple, the IETF draft `RateLimit-*`
|
||||
triple, the `Retry-After` denial signal, and the RFC 7807 problem
|
||||
detail body so the middleware itself stays thin and the formats are
|
||||
unit-testable in isolation.
|
||||
|
||||
Both header families are supported so consumers can opt into the IETF
|
||||
draft (RFC 9238) once their clients understand it without losing the
|
||||
legacy headers their tooling already reads. The Problem+JSON body
|
||||
matches the structure operators expect from a 429 — `type`, `title`,
|
||||
`status`, `detail`, plus the live `limit`, `remaining`, `retryAfter`
|
||||
fields clients use to retry intelligently.
|
||||
|
||||
## Features
|
||||
|
||||
- Three header styles: `"x-rate-limit"`, `"rfc-9238"`, or `"both"`.
|
||||
- `Retry-After` always whole-second-rounded, never `0` (minimum 1).
|
||||
- `X-RateLimit-Reset` carries Unix epoch seconds (legacy convention);
|
||||
`RateLimit-Reset` carries seconds-until-reset (RFC convention).
|
||||
- Problem+JSON body conforms to RFC 7807 `application/problem+json`
|
||||
shape — `type` is a stable URI, `status: 429` is literal.
|
||||
- Returned header bag is frozen so the middleware cannot mutate the
|
||||
shared object by accident.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildRateLimitHeaders } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const headers = buildRateLimitHeaders({
|
||||
decision,
|
||||
style: "x-rate-limit",
|
||||
emitRetryAfter: true,
|
||||
});
|
||||
for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildRateLimitHeaders,
|
||||
buildRateLimitProblem,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
if (!decision.allowed) {
|
||||
const headers = buildRateLimitHeaders({
|
||||
decision,
|
||||
style: "both",
|
||||
emitRetryAfter: true,
|
||||
});
|
||||
const body = buildRateLimitProblem(decision);
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 429,
|
||||
headers: { ...headers, "Content-Type": "application/problem+json" },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type RateLimitHeaders = Readonly<Record<string, string>>` — the
|
||||
frozen header bag.
|
||||
- `function buildRateLimitHeaders(args: { decision, style, emitRetryAfter, now? }): RateLimitHeaders`
|
||||
— assembles the header bag for a decision. `now` is injectable for
|
||||
deterministic tests; defaults to `Date.now()`.
|
||||
- `interface RateLimitProblemBody` — `{ type, title, status: 429,
|
||||
detail, limit, remaining, retryAfter }`.
|
||||
- `function buildRateLimitProblem(decision: RateLimitDecision): RateLimitProblemBody`
|
||||
— assembles the body for a denied decision.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `Math.ceil` is used for second conversion so a 1-ms remaining
|
||||
window does not round down to `0` and confuse a client that polls
|
||||
immediately.
|
||||
- The Problem `type` URI is a stable namespace under
|
||||
`https://suaveplan.dev/problems/` so consumers can ship a
|
||||
type-specific error page without further coordination.
|
||||
- The body intentionally omits the rate-limit key (PII risk) and the
|
||||
route id (information leakage). Clients learn the schedule from the
|
||||
headers, not the body.
|
||||
- `Retry-After` is only emitted on a denied decision and only when
|
||||
`emitRetryAfter` is `true` — operators who prefer pure headerless
|
||||
back-off can disable it.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `middleware/context-augmentation` — Hono context-variable augmentation
|
||||
|
||||
## Purpose
|
||||
|
||||
Augments Hono's `ContextVariableMap` interface so handlers calling
|
||||
`c.get("rateLimit")` get a typed `RateLimitDecision` rather than
|
||||
`unknown`. Without this declaration downstream handlers would have to
|
||||
narrow the value at the call site every time.
|
||||
|
||||
The augmentation is global (Hono's `ContextVariableMap` is a single
|
||||
declaration-merged interface across the whole project), so loading
|
||||
this module via the package barrel is sufficient — consumers do not
|
||||
need to import it explicitly.
|
||||
|
||||
## Features
|
||||
|
||||
- Single side-effect-free module that contributes one slot to
|
||||
`ContextVariableMap`.
|
||||
- The package barrel imports this file for its side effects so the
|
||||
augmentation surfaces transparently.
|
||||
- The slot type is exact (`RateLimitDecision`), not the loose
|
||||
`unknown` Hono ships by default.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import "@suaveplan/api-plugin-rate-limit"; // side-effect import surfaces the slot
|
||||
|
||||
const app = new Hono();
|
||||
app.get("/api/users", (c) => {
|
||||
const decision = c.get("rateLimit"); // typed RateLimitDecision
|
||||
return c.json({ remaining: decision.remaining });
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { RateLimitDecision } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
export const enrichResponseWithRateLimit = createMiddleware(async (c, next) => {
|
||||
await next();
|
||||
const decision: RateLimitDecision | undefined = c.get("rateLimit");
|
||||
if (decision !== undefined) {
|
||||
c.res.headers.set("X-Service-Quota", String(decision.remaining));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Module augmentation: `declare module "hono" { interface ContextVariableMap { rateLimit: RateLimitDecision } }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The slot is declared without `?` so handlers may not read it before
|
||||
the middleware has run. In practice this is fine — the middleware
|
||||
is the only thing that writes the slot and it does so before
|
||||
`next()`.
|
||||
- Adding additional slots requires editing this file and bumping the
|
||||
package's minor version (the augmentation is part of the public
|
||||
surface).
|
||||
- The augmentation file is co-located with the middleware that
|
||||
populates the slot so future readers find both in one place.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `middleware/rate-limit` — Hono middleware wrapping the rate-limit service
|
||||
|
||||
## Purpose
|
||||
|
||||
Constructs a Hono `MiddlewareHandler` that consults a
|
||||
`RateLimitService` on every request, emits the configured
|
||||
`X-RateLimit-*` / `RateLimit-*` headers on every response (allowed AND
|
||||
denied), and on denial writes an RFC 7807 `application/problem+json`
|
||||
body with the 429 status. The middleware is the canonical mount point
|
||||
operators wire into their app — `app.use("*", rateLimit({ service }))`
|
||||
covers every route; selective mounts let consumers apply different
|
||||
policies per path prefix.
|
||||
|
||||
The middleware also publishes the resolved `RateLimitDecision` to the
|
||||
Hono context (`c.set("rateLimit", decision)`) so downstream handlers
|
||||
can inspect the matched route, the remaining budget, or the resolved
|
||||
tenant id without re-running the check.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-request IP and tenant resolution via injectable resolvers
|
||||
(defaults: `X-Forwarded-For` / `X-Real-IP` for IP,
|
||||
`X-Tenant-ID` header for tenant).
|
||||
- Header bag merged onto the outgoing response after `next()` so
|
||||
downstream handlers cannot accidentally strip them.
|
||||
- RFC 7807 Problem+JSON body on 429.
|
||||
- `onDenied` hook fires before the response is written — useful for
|
||||
custom audit logging.
|
||||
- Decision attached to the Hono context so handlers can act on it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { rateLimit } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const app = new Hono();
|
||||
app.use("*", rateLimit({ service }));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
rateLimit,
|
||||
type RateLimitDecision,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"/api/*",
|
||||
rateLimit({
|
||||
service,
|
||||
headerStyle: "both",
|
||||
emitRetryAfter: true,
|
||||
resolveIp: (c) => c.req.header("CF-Connecting-IP") ?? "unknown",
|
||||
resolveTenantId: (c) => c.get("organisation")?.id,
|
||||
onDenied: (decision: RateLimitDecision) => {
|
||||
auditLog.warn("rate-limit denied", {
|
||||
routeId: decision.matchedRouteId,
|
||||
key: decision.key,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
app.get("/api/users", (c) => {
|
||||
const decision = c.get("rateLimit");
|
||||
return c.json({ remaining: decision.remaining });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RateLimitMiddlewareOptions` — `{ service, headerStyle?,
|
||||
emitRetryAfter?, resolveTenantId?, resolveIp?, onDenied? }`.
|
||||
- `function rateLimit(options: RateLimitMiddlewareOptions): MiddlewareHandler`
|
||||
— returns the Hono middleware. Defaults pulled from
|
||||
`service.config` when not overridden.
|
||||
- `function rateLimitExceeded(decision: RateLimitDecision): RateLimitPluginError`
|
||||
— construct a typed `LIMIT_EXCEEDED` error for callers that want to
|
||||
throw rather than return a Response.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The middleware returns the 429 response inline rather than throwing
|
||||
the typed error — operators who installed an error-handler
|
||||
translating `RateLimitPluginError` into Problem+JSON would get a
|
||||
double translation otherwise. Callers wiring `service.check`
|
||||
manually can use `rateLimitExceeded(decision)` to surface the same
|
||||
error through their handler chain.
|
||||
- Header bag is merged onto `c.res.headers` both before
|
||||
the denial response and after `next()` returns — Hono sometimes
|
||||
swaps the `res` between phases, so both writes are required.
|
||||
- The default IP resolver respects `X-Forwarded-For` (first hop) and
|
||||
falls back to `X-Real-IP`. Operators behind a trusted proxy should
|
||||
override this with a header their proxy guarantees (e.g.
|
||||
`CF-Connecting-IP` on Cloudflare).
|
||||
- The default tenant resolver reads `X-Tenant-ID` — consumers using
|
||||
JWT-derived tenant ids should override `resolveTenantId` to read
|
||||
from `c.get("user")`.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `otel/instruments` — OpenTelemetry instruments for the rate-limit plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds (and caches) the bundle of OpenTelemetry instruments the
|
||||
rate-limit plugin emits on every check. Operators wiring an SDK at
|
||||
boot get accurate counters and histograms without the plugin owning
|
||||
the SDK lifecycle. Tests installing an `InMemoryMetricExporter` can
|
||||
call `resetRateLimitInstruments()` to force a re-bind against the
|
||||
freshly-installed provider, so assertions run against real meter
|
||||
output rather than a snapshot taken before the provider was swapped
|
||||
in.
|
||||
|
||||
The bundle covers the four signals operators actually alert on: total
|
||||
checks (with outcome), denials (subset of checks), check duration, and
|
||||
bypasses (with reason).
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy, idempotent meter construction — first call binds against the
|
||||
global provider; subsequent calls return the cached bundle.
|
||||
- `resetRateLimitInstruments()` drops the cache so the next call
|
||||
re-binds; required for test isolation across files.
|
||||
- Counters and histograms carry stable units and descriptions so the
|
||||
collector's metadata pipeline can render dashboards correctly.
|
||||
- Compatible with `@opentelemetry/sdk-metrics` in-memory exporters,
|
||||
Prometheus exporters, and OTLP exporters out of the box.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getRateLimitInstruments } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const instruments = getRateLimitInstruments();
|
||||
instruments.checksTotal.add(1, { outcome: "allowed", routeId: "default" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
getRateLimitInstruments,
|
||||
resetRateLimitInstruments,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(/* … */);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 1_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetRateLimitInstruments();
|
||||
const instruments = getRateLimitInstruments();
|
||||
// every subsequent check() records into `exporter`.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RateLimitInstruments` — bundle of the four instruments:
|
||||
`checksTotal: Counter`, `denialsTotal: Counter`,
|
||||
`checkDurationMs: Histogram`, `bypassedTotal: Counter`.
|
||||
- `function getRateLimitInstruments(): RateLimitInstruments` — lazy
|
||||
accessor; returns the cached bundle bound to the current global
|
||||
meter provider.
|
||||
- `function resetRateLimitInstruments(): void` — drops the cache.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The meter name is `@suaveplan/api-plugin-rate-limit` so collectors
|
||||
can route metrics from this plugin to a dedicated scope without
|
||||
pattern-matching attribute values.
|
||||
- `checksTotal` and `denialsTotal` are intentionally overlapping —
|
||||
alerting on the rejection RATE is easier when the denominator
|
||||
(`checksTotal`) and numerator (`denialsTotal`) live in the same
|
||||
metric family.
|
||||
- The `outcome` attribute on `checksTotal` is one of
|
||||
`"allowed" | "denied" | "bypassed"`; the `routeId` attribute is the
|
||||
rule's `id` (or `"default"`); the `tenantId` attribute is present
|
||||
only when the request carried a resolved tenant.
|
||||
- Counter additions and histogram records are intentionally
|
||||
fire-and-forget — the OpenTelemetry API is non-blocking and any
|
||||
collector backpressure is the SDK's concern, not the plugin's.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `path-matcher` — Hono-compatible path-pattern compilation and matching
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Compiles a Hono-style path pattern string into a regular expression
|
||||
|
||||
+ parameter-name list, then matches it against concrete inbound paths
|
||||
and returns the captured params. The plugin pre-compiles every
|
||||
`RouteRule.path` once at init so per-request route lookup is constant
|
||||
time per rule and zero-allocation in the happy path.
|
||||
|
||||
The matcher accepts the same forms Hono's router accepts so route
|
||||
rules written for the framework's router transparently apply to
|
||||
rate-limit rules without translation. The compiled-pattern cache is
|
||||
exposed so consumers wiring `RateLimitService.check(...)` manually
|
||||
(without the middleware) can pre-compile their own rules.
|
||||
|
||||
## Features
|
||||
|
||||
+ Exact-literal, trailing wildcard (`/api/users/*`), inline segment
|
||||
wildcard (`/api/*/avatar`), and named-param (`/api/users/:id`)
|
||||
pattern forms.
|
||||
+ Captured params returned as a frozen `Record<string, string>` so
|
||||
callers can rely on referential stability.
|
||||
+ All metacharacters in the literal portions are escaped before
|
||||
pattern composition — `?`, `.`, `+` in a path do not turn into
|
||||
regex operators by accident.
|
||||
+ Defensive: every `RegExp` construction is wrapped so a future
|
||||
pattern-syntax mistake surfaces as `RateLimitPluginError(INVALID_PATH_PATTERN)`
|
||||
rather than a bare `SyntaxError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { isPathMatch } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
isPathMatch("/api/users/:id", "/api/users/123"); // true
|
||||
isPathMatch("/api/users/:id", "/api/posts/123"); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
compilePathPattern,
|
||||
matchPath,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const pattern = compilePathPattern("/api/orgs/:org/users/:user");
|
||||
const result = matchPath(pattern, "/api/orgs/acme/users/u42");
|
||||
if (result.matched) {
|
||||
console.log(result.params); // { org: "acme", user: "u42" }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
+ `interface CompiledPathPattern` — `{ source, regex, paramNames }`.
|
||||
+ `interface PathMatchResult` — `{ matched, params }`.
|
||||
+ `function compilePathPattern(source: string): CompiledPathPattern` —
|
||||
compile a pattern. Throws `RateLimitPluginError(INVALID_PATH_PATTERN)`
|
||||
on regex construction failure.
|
||||
+ `function matchPath(pattern: CompiledPathPattern, path: string): PathMatchResult`
|
||||
— match a compiled pattern against a path.
|
||||
+ `function isPathMatch(source: string, path: string): boolean` —
|
||||
compile + match convenience helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
+ Pattern compilation is `O(n)` over the source length; matching is
|
||||
`O(n)` over the path length once the regex is JIT'd by the engine.
|
||||
+ Trailing `/*` matches zero or more path segments (the trailing slash
|
||||
is optional), so `/api/users/*` matches both `/api/users/123` and
|
||||
`/api/users/` — but not `/api/users` (no trailing slash). This
|
||||
mirrors Hono's behavior.
|
||||
+ Inline `*` matches exactly one segment (`[^/]+`) — `/api/*/avatar`
|
||||
matches `/api/u1/avatar` but not `/api/u1/sub/avatar`.
|
||||
+ Named params (`:name`) bind to the captured value; the value is
|
||||
not currently used by the rate-limit service (rules apply to the
|
||||
pattern as a whole) but is preserved so downstream consumers may
|
||||
use it.
|
||||
+ The matcher is universal — zero runtime dependencies.
|
||||
100
docs/api-stack/api/api-plugin-rate-limit/src/plugin/plugin.md
Normal file
100
docs/api-stack/api/api-plugin-rate-limit/src/plugin/plugin.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# `plugin/plugin` — `createRateLimitPlugin` factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Factory that returns a typed `PluginDescriptor` for the rate-limit
|
||||
plugin, ready to be passed to `@suaveplan/api`'s plugin registry.
|
||||
The descriptor's `onInit` implements the canonical 6-step lifecycle
|
||||
shared by every `@suaveplan/api-plugin-*` package: resolve services,
|
||||
build storage, construct the service, register the service slot,
|
||||
install the Hono middleware, log readiness.
|
||||
|
||||
The factory Zod-validates the supplied options at the boundary so
|
||||
invalid input fails fast with `RateLimitPluginError(INVALID_INPUT)`
|
||||
carrying the underlying `ZodError` as `cause`. Once `onInit` returns
|
||||
the plugin is fully wired: every request that flows through the host
|
||||
Hono app is rate-limited by the configured policy, the
|
||||
`rateLimitService` slot is available for sibling plugins, and the
|
||||
plugin's OTel instruments are emitting against the host meter
|
||||
provider.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options at the boundary.
|
||||
- Six-step canonical `onInit` lifecycle.
|
||||
- Defaults to bundled `MemoryStorage`; accepts any
|
||||
`RateLimitStorage` for distributed deployments.
|
||||
- Registers `rateLimitService` in the host `ServiceRegistry`.
|
||||
- Installs the global Hono middleware on `*` automatically.
|
||||
- `onClose` releases the service so subsequent re-inits start fresh.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createApi } from "@suaveplan/api";
|
||||
import { createRateLimitPlugin } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const api = createApi();
|
||||
api.use(
|
||||
createRateLimitPlugin({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createApi } from "@suaveplan/api";
|
||||
import { createRateLimitPlugin } from "@suaveplan/api-plugin-rate-limit";
|
||||
import { createMyRedisStorage } from "./my-redis-storage.js";
|
||||
|
||||
const api = createApi();
|
||||
api.use(
|
||||
createRateLimitPlugin({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
routes: [
|
||||
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
|
||||
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
|
||||
],
|
||||
tenants: [
|
||||
{ tenantId: "enterprise", multiplier: 10 },
|
||||
{ tenantId: "internal", bypass: true },
|
||||
],
|
||||
ipWhitelist: ["10.0.0.0/8"],
|
||||
headerStyle: "both",
|
||||
emitRetryAfter: true,
|
||||
storage: createMyRedisStorage({ url: process.env.REDIS_URL }),
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function createRateLimitPlugin(options: RateLimitPluginOptions): PluginDescriptor<RateLimitPluginConfig>`
|
||||
— factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The 6-step `onInit` mirrors the shape every other
|
||||
`@suaveplan/api-plugin-*` follows so operators reading multiple
|
||||
plugins find a consistent narrative.
|
||||
- The plugin's `name` is `"rate-limit"` — sibling plugins that
|
||||
depend on it declare `dependencies: ["rate-limit"]` so the topo
|
||||
sort orders them correctly.
|
||||
- `onClose` nulls the service reference; the storage adapter (if
|
||||
caller-supplied) is the operator's responsibility to dispose.
|
||||
- The plugin DOES install a wildcard middleware (`app.use("*", …)`)
|
||||
by default. Consumers who want per-route middleware instead can
|
||||
skip the wildcard mount, resolve the service from the registry,
|
||||
and call `rateLimit({ service })` themselves — but the default is
|
||||
the safe, secure-by-default position.
|
||||
- The plugin requires the host `app` slot. The `clock` slot is
|
||||
optional and defaults to `Date.now`; the `logger` slot is also
|
||||
optional and defaults to `ctx.logger`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `schemas/options` — Zod schema for `createRateLimitPlugin` options
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates the caller-supplied `RateLimitPluginOptions` at the
|
||||
boundary and fills in defaults for every optional field, producing the
|
||||
resolved `RateLimitPluginConfig` the plugin's `onInit` lifecycle and
|
||||
the `RateLimitService` rely on. The schema is the single source of
|
||||
truth for what shape `createRateLimitPlugin` accepts — the factory
|
||||
calls `safeParse(...)` and throws `RateLimitPluginError(INVALID_INPUT)`
|
||||
with the underlying `ZodError` attached as `cause` on validation
|
||||
failure, so callers see a typed error rather than a runtime crash.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultLimit` / `defaultWindowMs` mandatory positive integers.
|
||||
- `routes`, `tenants`, `ipWhitelist` default to empty arrays.
|
||||
- `keyResolver` defaults to `${ip}|${tenantId ?? "_"}` — composable
|
||||
isolation between IPs and tenants out of the box.
|
||||
- `headerStyle` enum: `"x-rate-limit"` (default), `"rfc-9238"`,
|
||||
`"both"`.
|
||||
- `emitRetryAfter` defaults to `true`.
|
||||
- `storage` is a custom Zod runtime check accepting any
|
||||
`RateLimitStorage`-shaped object — caller-supplied adapters
|
||||
(Redis, SurrealDB) pass through untouched.
|
||||
- `.strict()` so typos in option names fail loudly instead of
|
||||
silently doing nothing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const config = RateLimitPluginOptionsSchema.parse({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RateLimitPluginOptionsSchema } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const result = RateLimitPluginOptionsSchema.safeParse({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
routes: [
|
||||
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
|
||||
{ path: "/api/users/:id", limit: 30, windowMs: 60_000 },
|
||||
],
|
||||
tenants: [
|
||||
{ tenantId: "enterprise", multiplier: 10 },
|
||||
{ tenantId: "internal", bypass: true },
|
||||
],
|
||||
ipWhitelist: ["127.0.0.1"],
|
||||
headerStyle: "both",
|
||||
});
|
||||
if (!result.success) console.error(result.error.issues);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RateLimitPluginOptionsSchema` — Zod schema instance. Parses
|
||||
`RateLimitPluginOptions` (input) into `RateLimitPluginConfig`
|
||||
(output) with every default filled in.
|
||||
- `RateLimitPluginOptionsInput` — `z.input<typeof
|
||||
RateLimitPluginOptionsSchema>` — what callers supply.
|
||||
- `RateLimitPluginOptionsResolved` — `z.output<typeof
|
||||
RateLimitPluginOptionsSchema>` — what the plugin receives.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `keyResolver` uses `z.custom<RateLimitKeyResolver>` — functions
|
||||
cannot be expressed as a JSON-Schema-compatible Zod shape; the
|
||||
custom check enforces `typeof === "function"`.
|
||||
- `storage` and `logger` are runtime references; the schema preserves
|
||||
them as-is via `z.custom` rather than attempting to validate their
|
||||
internal shape.
|
||||
- `defaults` declared inline so `RateLimitPluginOptionsResolved` is a
|
||||
total interface — no `undefined` fallbacks in the consumer.
|
||||
- The schema is `.strict()` to catch typos like
|
||||
`defaultLimti: 100` at the boundary.
|
||||
@@ -0,0 +1,122 @@
|
||||
# `services/rate-limit-service` — Plugin-level rate-limit service factory
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps the universal `@suaveplan/rate-limiter` core in the
|
||||
plugin's policy layer: IP whitelist, tenant bypass, tenant multiplier
|
||||
applied to the numeric limit, parameterised route matching, and OTel
|
||||
instrumentation. Exposed in the host `ServiceRegistry` under the
|
||||
`rateLimitService` slot so other plugins and route handlers can call
|
||||
`service.check({ ip, tenantId, path, method })` directly without
|
||||
going through the Hono middleware.
|
||||
|
||||
This service is the public extension point of the plugin — operators
|
||||
who want a custom storage backend, a custom key resolver, or
|
||||
fine-grained per-route policy use the same surface the middleware
|
||||
itself uses.
|
||||
|
||||
## Features
|
||||
|
||||
- IP whitelist short-circuits the entire check (bypassed counter
|
||||
recorded, no storage write).
|
||||
- Tenant bypass short-circuits per-tenant.
|
||||
- Tenant multiplier multiplies the matched rule's numeric `limit` and
|
||||
is wired into the algorithm itself (one limiter per
|
||||
(route, tenant) pair, lazily constructed).
|
||||
- Route lookup respects `method` when present and uses
|
||||
`compilePathPattern` for the `path`.
|
||||
- Storage failure is wrapped as `RateLimitPluginError(STORAGE_FAILURE)`
|
||||
so consumers can decide fail-open vs fail-closed.
|
||||
- OTel counters and histograms recorded on every code path
|
||||
(allowed / denied / bypassed).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDefaultStorage,
|
||||
createRateLimitService,
|
||||
RateLimitPluginOptionsSchema,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const config = RateLimitPluginOptionsSchema.parse({
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
});
|
||||
const service = createRateLimitService({
|
||||
config,
|
||||
storage: createDefaultStorage({ clock: Date.now }),
|
||||
clock: Date.now,
|
||||
logger: console,
|
||||
});
|
||||
const decision = await service.check({
|
||||
ip: "127.0.0.1",
|
||||
path: "/api/users",
|
||||
method: "GET",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createRateLimitService } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const service = createRateLimitService({
|
||||
config: {
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
routes: [
|
||||
{ path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" },
|
||||
],
|
||||
tenants: [
|
||||
{ tenantId: "enterprise", multiplier: 10 },
|
||||
{ tenantId: "internal", bypass: true },
|
||||
],
|
||||
ipWhitelist: ["127.0.0.1"],
|
||||
keyResolver: (input) => `${input.tenantId ?? "_"}::${input.ip}`,
|
||||
headerStyle: "rfc-9238",
|
||||
emitRetryAfter: true,
|
||||
},
|
||||
storage,
|
||||
clock,
|
||||
logger,
|
||||
});
|
||||
const decision = await service.check({
|
||||
ip: "10.0.0.42",
|
||||
tenantId: "enterprise",
|
||||
path: "/api/login",
|
||||
method: "POST",
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
return new Response("rate limit exceeded", { status: 429 });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RateLimitService` — `{ check, reset, config }`.
|
||||
- `interface RateLimitCheckInput` — `{ ip, tenantId?, path, method }`.
|
||||
- `function createRateLimitService(args: { config, storage, clock,
|
||||
logger }): RateLimitService`.
|
||||
- `function createDefaultStorage(args: { clock }): RateLimitStorage` —
|
||||
`MemoryStorage` wrapped with the injected clock.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Lazy limiter construction (one per `(routeId, tenantId)` pair) keeps
|
||||
init cost proportional to the configured routes, not the number of
|
||||
active tenants. The first hit by tenant `T` against route `R`
|
||||
builds a fresh limiter; subsequent hits reuse it.
|
||||
- The reset path iterates every limiter built so far — operators
|
||||
calling `service.reset(key)` after a successful login pay
|
||||
`O(routes × tenants_seen)` for the broadcast. In practice both
|
||||
factors are small.
|
||||
- The default route (no rule matched) uses `id: "default"` and the
|
||||
plugin's `defaultLimit` / `defaultWindowMs`. It is treated as a
|
||||
rule like any other so the tenant multiplier and storage isolation
|
||||
apply uniformly.
|
||||
- OTel attributes always carry the `routeId` and (when present) the
|
||||
`tenantId` — alerts can filter by either dimension without
|
||||
re-aggregation.
|
||||
- `RateLimitPluginError(STORAGE_FAILURE)` carries the original error
|
||||
as `cause` so structured logs preserve the stack trace.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `services/registry-augmentation` — TypeScript module augmentation for the host ServiceRegistry
|
||||
|
||||
## Purpose
|
||||
|
||||
Augments the `ServiceRegistry` interface owned by
|
||||
`@suaveplan/api-plugin/service-registry` so consumers see the
|
||||
`rateLimitService` slot typed as `RateLimitService` (rather than
|
||||
`unknown`) when they call `ctx.getService("rateLimitService")`. Without
|
||||
this declaration the host's typed lookup would fall back to the
|
||||
default `unknown` and downstream plugins would have to cast.
|
||||
|
||||
Re-declares the canonical host-side slots (`app`, `logger`, `eventBus`,
|
||||
`clock`) too so the plugin's own `onInit(ctx)` can resolve them
|
||||
without forcing the consumer to import `@suaveplan/api`'s
|
||||
registry-augmentation manually. TypeScript merges duplicate `declare
|
||||
module` blocks, so this addition is purely additive.
|
||||
|
||||
## Features
|
||||
|
||||
- Module augmentation lives in a side-effect-only import — the
|
||||
package barrel imports it first so the declaration is in scope for
|
||||
every consumer.
|
||||
- Exports a no-op `registerRateLimitServiceSlot()` so the module is
|
||||
preserved through dead-code elimination and a side-effect import
|
||||
surfaces the augmentation reliably.
|
||||
- Cumulative — does not collide with `@suaveplan/api`'s
|
||||
augmentation; TypeScript merges them.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Anywhere that imports the package barrel:
|
||||
import "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
// `ctx.getService("rateLimitService")` now narrows to
|
||||
// `RateLimitService | undefined`.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Inside another plugin's onInit:
|
||||
import { registerRateLimitServiceSlot } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
registerRateLimitServiceSlot(); // ensures bundler keeps the side-effect import
|
||||
const service = ctx.getService("rateLimitService");
|
||||
if (service === undefined) {
|
||||
throw new MyError(MY_CODES.MISSING_DEP, "rateLimitService not registered");
|
||||
}
|
||||
await service.check({ ip, path, method });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Module augmentation: `declare module "@suaveplan/api-plugin/service-registry"`
|
||||
adds `rateLimitService: RateLimitService` plus the canonical host
|
||||
slots.
|
||||
- `function registerRateLimitServiceSlot(): void` — no-op runtime
|
||||
export. Call it inside a function body if your bundler is
|
||||
aggressive about pruning side-effect-only imports.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The declaration uses a regular `import type` chain so the runtime
|
||||
cost is zero — no module is loaded at runtime to satisfy the
|
||||
augmentation.
|
||||
- The host `app` slot is typed as `OpenAPIHono` from
|
||||
`@hono/zod-openapi` to mirror `@suaveplan/api`'s declaration. Plain
|
||||
`Hono` apps work too; the `OpenAPIHono` type is a structural
|
||||
superset.
|
||||
- Adding new slots requires updating the augmentation here AND
|
||||
publishing a major version — consumers' compile output depends on
|
||||
the interface shape.
|
||||
108
docs/api-stack/api/api-plugin-rate-limit/src/types/types.md
Normal file
108
docs/api-stack/api/api-plugin-rate-limit/src/types/types.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `types/types` — Public type surface of the rate-limit plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the public type surface every other module in the package
|
||||
references — the caller-facing `RateLimitPluginOptions`, the resolved
|
||||
`RateLimitPluginConfig`, the `RouteRule` and `TenantRule` shapes, the
|
||||
`RateLimitDecision` returned by `service.check(...)`, and the
|
||||
`RateLimitKeyResolver` callers can plug in to customise key
|
||||
derivation. These types are the contract; the schema in
|
||||
`schemas/options.ts` is the runtime validator that enforces them.
|
||||
|
||||
Co-locating the type surface here keeps the module-by-module reading
|
||||
order predictable: read this file, then `schemas/options.ts`, then
|
||||
`services/rate-limit-service.ts`, then `middleware/rate-limit.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- Every option is documented with prose describing the runtime
|
||||
semantics, not just the static shape.
|
||||
- `RouteRule` accepts three pattern forms (exact, `/*` suffix,
|
||||
`:param` named segments) consistent with Hono's router.
|
||||
- `TenantRule.multiplier` is APPLIED to the numeric limit (legacy
|
||||
upstream plugin only used it as a bucket-key prefix).
|
||||
- `RateLimitKeyResolver` is a pure function — no async, no side
|
||||
effects — so the resolver can run in the hot path safely.
|
||||
- `RateLimitDecision` carries every field the middleware needs to
|
||||
build headers and the body without an extra round trip to the
|
||||
service.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RateLimitDecision,
|
||||
RateLimitPluginOptions,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const opts: RateLimitPluginOptions = {
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
};
|
||||
|
||||
function logDecision(d: RateLimitDecision): void {
|
||||
console.log(`allowed=${d.allowed} remaining=${d.remaining}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RateLimitKeyResolver,
|
||||
RateLimitPluginOptions,
|
||||
RouteRule,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const byUser: RateLimitKeyResolver = (input) =>
|
||||
`${input.tenantId ?? "_"}::${input.ip}::${input.method}`;
|
||||
|
||||
const loginRule: RouteRule = {
|
||||
path: "/api/auth/:provider/login",
|
||||
method: "POST",
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
id: "login-per-provider",
|
||||
};
|
||||
|
||||
const opts: RateLimitPluginOptions = {
|
||||
defaultLimit: 100,
|
||||
defaultWindowMs: 60_000,
|
||||
routes: [loginRule],
|
||||
keyResolver: byUser,
|
||||
headerStyle: "rfc-9238",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type RateLimitHeaderStyle = "x-rate-limit" | "rfc-9238" | "both"`.
|
||||
- `interface RouteRule` — `{ path, method?, limit, windowMs, id? }`.
|
||||
- `interface TenantRule` — `{ tenantId, multiplier?, bypass? }`.
|
||||
- `interface RateLimitPluginOptions` — caller-facing.
|
||||
- `interface RateLimitPluginConfig` — resolved, complete config.
|
||||
- `type RateLimitKeyResolver = (input: RateLimitKeyResolverInput) => string`.
|
||||
- `interface RateLimitKeyResolverInput` — `{ ip, tenantId, path, method }`.
|
||||
- `interface RateLimitDecision` — `{ allowed, limit, remaining,
|
||||
resetIn, retryAfter, bypassed, matchedRouteId, tenantId, key }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Every interface is `readonly` to make the shape immutable at
|
||||
compile time; consumers building options assemble plain objects
|
||||
that satisfy the shape without `as const` ceremony.
|
||||
- `RateLimitKeyResolver` is intentionally synchronous — running an
|
||||
async resolver per request would balloon the hot path. Operators
|
||||
who need async tenant lookup do that ONCE inside their auth
|
||||
middleware and stash the result in the Hono context for the
|
||||
rate-limit middleware to read synchronously.
|
||||
- `RateLimitConfig` and `RateLimitStorage` are re-exported from
|
||||
`@suaveplan/rate-limiter` so consumers writing custom storage
|
||||
adapters do not need to add another package dependency.
|
||||
- The decision's `key` field is the resolved storage key — exposed
|
||||
for debug + admin endpoints, not for client consumption.
|
||||
Reference in New Issue
Block a user