Initial Commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# @suaveplan/network-plugin-middleware - Priority-ordered middleware chain for the Suaveplan mesh: manager + logging, validation, transformation, metrics, throttling, security, i18n
|
||||
|
||||
## Summary
|
||||
|
||||
Priority-ordered middleware chain for the Suaveplan mesh. Inbound and outbound message flow each pass through the same five-band pipeline (CRITICAL → HIGH → NORMAL → LOW → BACKGROUND) with runtime add/remove/enable/disable. Ships seven concrete middlewares — `LoggingMiddleware`, `ValidationMiddleware` (Zod, strict / lenient), `TransformationMiddleware`, `MetricsMiddleware` (size + processing time), `ThrottlingMiddleware` (`@suaveplan/rate-limiter` token-bucket; global / per-peer / per-type), `SecurityMiddleware` (injectable sign / verify hooks per tier policy), and `I18nMiddleware` (locale headers, RTL marker, UTC timestamp normalisation) — wrapped in a single `MiddlewarePlugin` that satisfies the `NetworkPlugin` lifecycle.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [i18n](./src/i18n/i18n-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [i18n-middleware](./src/i18n/i18n-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [logging](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [logging-middleware](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [middleware-manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics-middleware](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [middleware-plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [security](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [security-middleware](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [throttling](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [throttling-middleware](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transformation](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transformation-middleware](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [validation](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [validation-middleware](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the middleware chain. Each code maps to one failure class so consumers ca…
|
||||
<!-- end:auto-docs --> - [i18n-middleware](./src/i18n/i18n-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [logging](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [logging-middleware](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware-manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-middleware](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware-plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-middleware](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [throttling](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [throttling-middleware](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [transformation](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [transformation-middleware](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-middleware](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the middleware chain. Each code maps to one failure class so consumers ca…
|
||||
- [i18n-middleware](./src/i18n/i18n-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [logging](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [logging-middleware](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware-manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-middleware](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware-plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-middleware](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [throttling](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [throttling-middleware](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [transformation](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [transformation-middleware](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-middleware](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the middleware chain. Each code maps to one failure class so consumers ca…
|
||||
- [i18n-middleware](./src/i18n/i18n-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [logging](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [logging-middleware](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware-manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-middleware](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware-plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-middleware](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [throttling](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [throttling-middleware](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [transformation](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [transformation-middleware](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-middleware](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the middleware chain. Each code maps to one failure class so consumers ca…
|
||||
- [i18n-middleware](./src/i18n/i18n-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [logging](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [logging-middleware](./src/logging/logging-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [middleware-manager](./src/manager/middleware-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics-middleware](./src/metrics/metrics-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [middleware-plugin](./src/plugin/middleware-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [security](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [security-middleware](./src/security/security-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [throttling](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [throttling-middleware](./src/throttling/throttling-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [transformation](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transformation-middleware](./src/transformation/transformation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validation-middleware](./src/validation/validation-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the middleware chain. Each code maps to one failure class so consumers ca…
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-middleware",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Priority-ordered middleware chain for the Suaveplan mesh: manager + logging, validation, transformation, metrics, throttling, security, i18n",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/network": "workspace:^",
|
||||
"@suaveplan/rate-limiter": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MiddlewareManager } from "../manager/middleware-manager.js";
|
||||
|
||||
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
|
||||
createEnvelope({
|
||||
type: "chain:test",
|
||||
senderId: "peer-1",
|
||||
payload: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Chain semantics: null-drop, error-policy, direction isolation", () => {
|
||||
it("null return at any step short-circuits and reports droppedBy", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
const seen: string[] = [];
|
||||
manager.register({
|
||||
id: "first",
|
||||
priority: 10,
|
||||
apply: (e) => {
|
||||
seen.push("first");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
manager.register({
|
||||
id: "drop-here",
|
||||
priority: 20,
|
||||
apply: () => {
|
||||
seen.push("drop-here");
|
||||
return null;
|
||||
},
|
||||
});
|
||||
manager.register({
|
||||
id: "after",
|
||||
priority: 30,
|
||||
apply: (e) => {
|
||||
seen.push("after");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
const result = await manager.dispatch(env(), "outgoing");
|
||||
expect(result.dropped).toBe(true);
|
||||
expect(result.droppedBy).toBe("drop-here");
|
||||
expect(result.executed).toEqual(["first", "drop-here"]);
|
||||
expect(seen).toEqual(["first", "drop-here"]);
|
||||
});
|
||||
|
||||
it("incoming and outgoing chains are isolated", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
const calls: string[] = [];
|
||||
manager.register({
|
||||
id: "in-only",
|
||||
priority: 50,
|
||||
direction: "incoming",
|
||||
apply: (e) => {
|
||||
calls.push("in-only");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
manager.register({
|
||||
id: "out-only",
|
||||
priority: 50,
|
||||
direction: "outgoing",
|
||||
apply: (e) => {
|
||||
calls.push("out-only");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
await manager.dispatch(env(), "incoming");
|
||||
expect(calls).toEqual(["out-only", "in-only"]);
|
||||
});
|
||||
|
||||
it("envelope flows through every middleware in order", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register({
|
||||
id: "tag-a",
|
||||
priority: 100,
|
||||
apply: (e) => ({
|
||||
...e,
|
||||
headers: { ...e.headers, "x-a": "1" },
|
||||
}),
|
||||
});
|
||||
manager.register({
|
||||
id: "tag-b",
|
||||
priority: 200,
|
||||
apply: (e) => ({
|
||||
...e,
|
||||
headers: { ...e.headers, "x-b": "1" },
|
||||
}),
|
||||
});
|
||||
const result = await manager.dispatch(env(), "outgoing");
|
||||
expect(result.envelope?.headers["x-a"]).toBe("1");
|
||||
expect(result.envelope?.headers["x-b"]).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
# Error codes — `@suaveplan/network-plugin-middleware`
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of every error code raised by the middleware chain. Each code maps to one failure class so consumers can branch on `error.code` without parsing message text. Codes are namespaced under `NET_MW` and registered into the global error registry on first import via the side-effect import in `src/index.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- Six codes covering registration, validation, throttling, security, and generic chain errors.
|
||||
- `NetworkMwError` extends `SuaveplanError<NetworkMwErrorCode>` so cause-chain traversal, redaction, and OTel adapters work without per-package wiring.
|
||||
- Typed union `NetworkMwErrorCode` exhaustively covers the closed registry so callers using `switch` get exhaustiveness checking.
|
||||
- Frozen registry — runtime mutation throws.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_MW_CODES,
|
||||
NetworkMwError,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.VALIDATION_FAILED,
|
||||
"validation.bad-payload",
|
||||
{ context: { messageType: "registry:add", reason: "missing peerId" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
try {
|
||||
await throttler.apply(envelope, context);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NetworkMwError &&
|
||||
err.code === NETWORK_MW_CODES.THROTTLED
|
||||
) {
|
||||
return { dropped: true, reason: "rate-limit", retryAfter: 1_000 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_MW_CODES` — frozen object of `KEY -> "NET_MW.KEY"`. Throws on mutation attempts.
|
||||
- `NetworkMwErrorCode` — TypeScript union of every code value.
|
||||
- `NetworkMwError` — `SuaveplanError<NetworkMwErrorCode>` subclass; `name === "NetworkMwError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is constructed via `defineCodes("NET_MW", […] as const)` so the type system enumerates the value union. Adding a code requires bumping the `as const` tuple — TypeScript will then propagate the new key everywhere `NetworkMwErrorCode` is referenced.
|
||||
- Codes are deliberately coarse (one per failure class) rather than per-call-site so consumers can build stable retry / observability policies without churn when implementations change.
|
||||
- `MIDDLEWARE_ERROR` is the catch-all the manager raises when a middleware throws under the `error-policy: "stop"` strategy. Specific middlewares (validation, throttling, security) prefer their own dedicated codes for richer downstream logic.
|
||||
- The side-effect import in `src/index.ts` registers the codes even when consumers tree-shake the named exports.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_MW_CODES, NetworkMwError } from "./error-codes.js";
|
||||
|
||||
describe("NETWORK_MW_CODES", () => {
|
||||
it("registers under NET_MW", () => {
|
||||
for (const value of Object.values(NETWORK_MW_CODES)) {
|
||||
expect(value).toMatch(/^NET_MW\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(NETWORK_MW_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains the documented codes", () => {
|
||||
for (const key of [
|
||||
"MIDDLEWARE_ALREADY_REGISTERED",
|
||||
"MIDDLEWARE_NOT_REGISTERED",
|
||||
"VALIDATION_FAILED",
|
||||
"THROTTLED",
|
||||
"SECURITY_REJECTED",
|
||||
"MIDDLEWARE_ERROR",
|
||||
] as const) {
|
||||
expect(NETWORK_MW_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkMwError", () => {
|
||||
it("extends SuaveplanError", () => {
|
||||
const err = new NetworkMwError(
|
||||
NETWORK_MW_CODES.VALIDATION_FAILED,
|
||||
"rejected"
|
||||
);
|
||||
expect(err.name).toBe("NetworkMwError");
|
||||
expect(err.code).toBe(NETWORK_MW_CODES.VALIDATION_FAILED);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NetworkMwError(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ERROR,
|
||||
"with-cause",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every error code thrown by
|
||||
* `@suaveplan/network-plugin-middleware`.
|
||||
*/
|
||||
export const NETWORK_MW_CODES = defineCodes("NET_MW", [
|
||||
"MIDDLEWARE_ALREADY_REGISTERED",
|
||||
"MIDDLEWARE_NOT_REGISTERED",
|
||||
"VALIDATION_FAILED",
|
||||
"THROTTLED",
|
||||
"SECURITY_REJECTED",
|
||||
"MIDDLEWARE_ERROR",
|
||||
] as const);
|
||||
|
||||
export type NetworkMwErrorCode =
|
||||
(typeof NETWORK_MW_CODES)[keyof typeof NETWORK_MW_CODES];
|
||||
|
||||
export class NetworkMwError extends SuaveplanError<NetworkMwErrorCode> {
|
||||
public constructor(
|
||||
code: NetworkMwErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetworkMwError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# `I18nMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Normalises locale-related metadata across the chain. Injects an `x-locale` header, sets `x-direction: rtl` for right-to-left scripts, and (when configured with a clock) rewrites the envelope's timestamp to a single reference frame so peers in different timezones do not produce comparison-resistant timestamps.
|
||||
|
||||
## Features
|
||||
|
||||
- BCP-47 locale tag carried in the `x-locale` header so receivers can switch translations / formatters without inspecting the payload.
|
||||
- RTL detection via configurable locale-prefix list (defaults to `ar`, `he`, `fa`, `ur` — covers the bulk of right-to-left scripts).
|
||||
- Optional UTC normalisation: callers pass a `clock` (typically `Date.now`) and the middleware rewrites `envelope.timestamp` so cross-host comparisons stay reliable.
|
||||
- Lives in the NORMAL band by default (`priority: 280`) so locale headers are present before transformation / metrics observe them.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
I18nMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(new I18nMiddleware({ locale: "ar-EG" }));
|
||||
const result = await manager.dispatch(envelope, "outgoing");
|
||||
result.envelope?.headers["x-locale"]; // "ar-EG"
|
||||
result.envelope?.headers["x-direction"]; // "rtl"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { I18nMiddleware } from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const i18n = new I18nMiddleware({
|
||||
id: "i18n-utc",
|
||||
priority: 290,
|
||||
locale: "en-US",
|
||||
rtlLocales: ["ar", "he", "fa", "ur", "yi"],
|
||||
clock: () => now,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class I18nMiddleware implements Middleware`
|
||||
- `constructor(config?: I18nMiddlewareConfig)`.
|
||||
- `apply(envelope, context): MessageEnvelope` — never drops.
|
||||
- `interface I18nMiddlewareConfig` — `{ id?, priority?, direction?, locale?, rtlLocales?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- RTL detection compares the lower-cased locale tag against each prefix. A locale matches the prefix exactly (`"ar"`) or hyphen-bounded (`"ar-EG"`); `"argonaut"` does NOT match the `ar` prefix because the comparator only allows the empty suffix or a `-` separator.
|
||||
- The middleware always returns a new envelope object; even when it leaves the headers untouched, callers can rely on identity-equality being a useful "did i18n run?" signal.
|
||||
- The clock rewrite is opt-in: applications that already enforce monotonic timestamps elsewhere (e.g. via `network-plugin-protocol`) should leave it unset to avoid double-normalisation.
|
||||
- `x-locale` is the conventional WebSocket extension header, deliberately reused so subscribers (logger, metrics) do not need to learn a new key per protocol surface.
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export interface I18nMiddlewareConfig {
|
||||
readonly id?: string;
|
||||
readonly priority?: number;
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
/** BCP-47 locale tag (default `"en"`). */
|
||||
readonly locale?: string;
|
||||
/** Set of locale prefixes considered RTL (default `["ar", "he", "fa", "ur"]`). */
|
||||
readonly rtlLocales?: ReadonlyArray<string>;
|
||||
/** Optional clock — when set, the middleware rewrites `envelope.timestamp` to `clock()`. */
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_RTL = Object.freeze(["ar", "he", "fa", "ur"]);
|
||||
|
||||
/**
|
||||
* Locale-aware envelope normalisation: injects `x-locale` and (when
|
||||
* applicable) `x-direction: rtl` headers, and optionally normalises
|
||||
* `envelope.timestamp` to a caller-supplied clock so timestamps from
|
||||
* peers in other timezones land in a single reference frame.
|
||||
*/
|
||||
export class I18nMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly locale: string;
|
||||
private readonly rtlPrefixes: ReadonlyArray<string>;
|
||||
private readonly clock: (() => number) | undefined;
|
||||
|
||||
public constructor(config: I18nMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "i18n";
|
||||
this.priority = config.priority ?? 280;
|
||||
this.direction = config.direction;
|
||||
this.locale = config.locale ?? "en";
|
||||
this.rtlPrefixes = config.rtlLocales ?? DEFAULT_RTL;
|
||||
this.clock = config.clock;
|
||||
}
|
||||
|
||||
public apply(
|
||||
envelope: MessageEnvelope,
|
||||
_context: MiddlewareContext
|
||||
): MessageEnvelope {
|
||||
const headers = { ...envelope.headers };
|
||||
headers["x-locale"] = this.locale;
|
||||
if (this.isRtl(this.locale)) {
|
||||
headers["x-direction"] = "rtl";
|
||||
}
|
||||
const next: MessageEnvelope = {
|
||||
...envelope,
|
||||
headers,
|
||||
};
|
||||
if (this.clock !== undefined) {
|
||||
return { ...next, timestamp: this.clock() };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private isRtl(locale: string): boolean {
|
||||
const lower = locale.toLowerCase();
|
||||
for (const prefix of this.rtlPrefixes) {
|
||||
if (lower === prefix || lower.startsWith(`${prefix}-`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { I18nMiddleware } from "./i18n-middleware.js";
|
||||
|
||||
const env = () =>
|
||||
createEnvelope({ type: "i18n:test", senderId: "peer-1", payload: {} });
|
||||
|
||||
const ctx = {
|
||||
direction: "outgoing" as const,
|
||||
state: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
describe("I18nMiddleware: locale headers, RTL, UTC normalisation", () => {
|
||||
it("injects x-locale header (default en, no x-direction)", () => {
|
||||
const i = new I18nMiddleware();
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-locale"]).toBe("en");
|
||||
expect(result.headers["x-direction"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits x-direction: rtl for RTL locales", () => {
|
||||
const i = new I18nMiddleware({ locale: "ar-EG" });
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-direction"]).toBe("rtl");
|
||||
});
|
||||
|
||||
it("matches RTL prefix exactly (no false positives)", () => {
|
||||
const i = new I18nMiddleware({ locale: "argonaut" });
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-direction"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches when locale equals an RTL prefix exactly", () => {
|
||||
const i = new I18nMiddleware({ locale: "he" });
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-direction"]).toBe("rtl");
|
||||
});
|
||||
|
||||
it("rewrites timestamp when clock is provided", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const i = new I18nMiddleware({ clock: () => now });
|
||||
const e = env();
|
||||
const before = e.timestamp;
|
||||
const result = i.apply(e, ctx);
|
||||
expect(result.timestamp).toBe(now);
|
||||
expect(result.timestamp).not.toBe(before);
|
||||
});
|
||||
|
||||
it("custom rtlLocales overrides defaults", () => {
|
||||
const i = new I18nMiddleware({
|
||||
locale: "yi",
|
||||
rtlLocales: ["yi"],
|
||||
});
|
||||
expect(i.apply(env(), ctx).headers["x-direction"]).toBe("rtl");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// Error codes (side-effect registration)
|
||||
import "./error-codes.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
NETWORK_MW_CODES,
|
||||
NetworkMwError,
|
||||
type NetworkMwErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// I18n
|
||||
export {
|
||||
I18nMiddleware,
|
||||
type I18nMiddlewareConfig,
|
||||
} from "./i18n/i18n-middleware.js";
|
||||
// Logging
|
||||
export {
|
||||
type LoggingBus,
|
||||
type LoggingFormatter,
|
||||
LoggingMiddleware,
|
||||
type LoggingMiddlewareConfig,
|
||||
} from "./logging/logging-middleware.js";
|
||||
// Manager
|
||||
export {
|
||||
MiddlewareManager,
|
||||
priorityForBand,
|
||||
} from "./manager/middleware-manager.js";
|
||||
// Metrics
|
||||
export {
|
||||
type MessageMetrics,
|
||||
type MetricsBus,
|
||||
MetricsMiddleware,
|
||||
type MetricsMiddlewareConfig,
|
||||
} from "./metrics/metrics-middleware.js";
|
||||
// Plugin
|
||||
export {
|
||||
MiddlewarePlugin,
|
||||
type MiddlewarePluginConfig,
|
||||
} from "./plugin/middleware-plugin.js";
|
||||
// Security
|
||||
export {
|
||||
type EncryptFn,
|
||||
SecurityMiddleware,
|
||||
type SecurityMiddlewareConfig,
|
||||
type SecurityPolicy,
|
||||
type SecurityPolicyEntry,
|
||||
type SignFn,
|
||||
type TierName,
|
||||
type VerifyFn,
|
||||
} from "./security/security-middleware.js";
|
||||
// Throttling
|
||||
export {
|
||||
type ThrottleLimit,
|
||||
ThrottlingMiddleware,
|
||||
type ThrottlingMiddlewareConfig,
|
||||
} from "./throttling/throttling-middleware.js";
|
||||
// Transformation
|
||||
export {
|
||||
TransformationMiddleware,
|
||||
type TransformationMiddlewareConfig,
|
||||
type TransformFn,
|
||||
} from "./transformation/transformation-middleware.js";
|
||||
// Types
|
||||
export type {
|
||||
ChainResult,
|
||||
ErrorPolicy,
|
||||
ErrorPolicyConfig,
|
||||
} from "./types/chain.types.js";
|
||||
export type {
|
||||
ChainDirection,
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
MiddlewarePriority,
|
||||
} from "./types/middleware.types.js";
|
||||
// Validation
|
||||
export {
|
||||
type ValidationLogger,
|
||||
ValidationMiddleware,
|
||||
type ValidationMiddlewareConfig,
|
||||
type ValidationMode,
|
||||
type ValidationWarning,
|
||||
} from "./validation/validation-middleware.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `LoggingMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Records every envelope that traverses the chain as a structured log entry. Pluggable formatter, optional payload preview with size cap, and optional bus publish (`middleware:logged`) so a single registered logger feeds whatever observability stack the deployment runs (console, JSON sink, OTel adapter).
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable id and priority (default `"logger"`, `300` so it lives in the LOW band — observability runs after the substantive middleware).
|
||||
- `includeBody` toggle: off by default so logs do not accidentally leak sensitive payloads; when on, payloads are clipped to `maxBodyChars` (default 1024) and JSON-serialised when possible.
|
||||
- `formatter` knob lets callers shape the entry (newline-delimited JSON, OTel `LogRecord`, …) without subclassing.
|
||||
- `bus` injection is optional — leave it unset if the consumer wants to call the formatter manually or attach via OTel.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LoggingMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(new LoggingMiddleware({ includeBody: true }));
|
||||
await manager.dispatch(envelope, "outgoing");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LoggingMiddleware,
|
||||
MiddlewareManager,
|
||||
type LoggingBus,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const events: unknown[] = [];
|
||||
const bus: LoggingBus = {
|
||||
emit: (_event, payload) => events.push(payload.entry),
|
||||
};
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(
|
||||
new LoggingMiddleware({
|
||||
id: "wire-tap",
|
||||
priority: 410, // BACKGROUND
|
||||
includeBody: true,
|
||||
maxBodyChars: 256,
|
||||
bus,
|
||||
formatter: ({ envelope, context, bodyPreview }) => ({
|
||||
ts: envelope.timestamp,
|
||||
t: envelope.type,
|
||||
d: context.direction,
|
||||
preview: bodyPreview,
|
||||
}),
|
||||
})
|
||||
);
|
||||
await manager.dispatch(envelope, "incoming");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LoggingMiddleware implements Middleware`
|
||||
- `constructor(config?: LoggingMiddlewareConfig)`.
|
||||
- `apply(envelope, context): MessageEnvelope` — never drops.
|
||||
- `interface LoggingMiddlewareConfig` — `{ id?, priority?, direction?, includeBody?, maxBodyChars?, bus?, formatter? }`.
|
||||
- `interface LoggingBus` — `{ emit("middleware:logged", payload): void }`.
|
||||
- `type LoggingFormatter` — `(input) => unknown`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default formatter returns a frozen object so consumers cannot accidentally mutate the entry between emit and observation.
|
||||
- Body serialisation uses `JSON.stringify` with a `try`/`catch` fallback to `String(...)` so circular references or `BigInt` payloads do not blow up the chain. Callers that care about exact serialisation should pass a custom formatter and skip the built-in preview.
|
||||
- The body cap is character-based, not byte-based, because the entry travels through structured logs that consume characters; any byte-budget conversion belongs in the sink.
|
||||
- Payload preview is suppressed when `includeBody === false` even if a custom formatter requests it — the formatter receives `bodyPreview: null` so callers cannot accidentally bypass the toggle through formatter composition.
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export type LoggingFormatter = (input: {
|
||||
envelope: MessageEnvelope;
|
||||
context: MiddlewareContext;
|
||||
bodyPreview: string | null;
|
||||
}) => unknown;
|
||||
|
||||
export interface LoggingBus {
|
||||
emit(
|
||||
event: "middleware:logged",
|
||||
payload: {
|
||||
middlewareId: string;
|
||||
direction: MiddlewareContext["direction"];
|
||||
messageId: string;
|
||||
messageType: string;
|
||||
entry: unknown;
|
||||
}
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface LoggingMiddlewareConfig {
|
||||
/** Defaults to `"logger"`. */
|
||||
readonly id?: string;
|
||||
/** Defaults to `300` (LOW band). */
|
||||
readonly priority?: number;
|
||||
/** Optional direction filter. */
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
/** Whether to include the payload preview. Defaults to `false`. */
|
||||
readonly includeBody?: boolean;
|
||||
/** Maximum payload preview length (characters). Defaults to 1024. */
|
||||
readonly maxBodyChars?: number;
|
||||
/** Optional bus to publish each entry to. */
|
||||
readonly bus?: LoggingBus;
|
||||
/** Optional custom formatter. Defaults to a structured object. */
|
||||
readonly formatter?: LoggingFormatter;
|
||||
}
|
||||
|
||||
const DEFAULT_FORMATTER: LoggingFormatter = ({
|
||||
envelope,
|
||||
context,
|
||||
bodyPreview,
|
||||
}) =>
|
||||
Object.freeze({
|
||||
id: envelope.id,
|
||||
type: envelope.type,
|
||||
senderId: envelope.senderId,
|
||||
direction: context.direction,
|
||||
timestamp: envelope.timestamp,
|
||||
bodyPreview,
|
||||
});
|
||||
|
||||
/**
|
||||
* Records every envelope that flows through the chain as a structured
|
||||
* log entry. Optionally publishes to a bus topic
|
||||
* (`middleware:logged`) for aggregation by `network-plugin-monitor`
|
||||
* or any custom subscriber.
|
||||
*/
|
||||
export class LoggingMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly includeBody: boolean;
|
||||
private readonly maxBodyChars: number;
|
||||
private readonly bus: LoggingBus | undefined;
|
||||
private readonly formatter: LoggingFormatter;
|
||||
|
||||
public constructor(config: LoggingMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "logger";
|
||||
this.priority = config.priority ?? 300;
|
||||
this.direction = config.direction;
|
||||
this.includeBody = config.includeBody ?? false;
|
||||
this.maxBodyChars = config.maxBodyChars ?? 1024;
|
||||
this.bus = config.bus;
|
||||
this.formatter = config.formatter ?? DEFAULT_FORMATTER;
|
||||
}
|
||||
|
||||
public apply(
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
): MessageEnvelope {
|
||||
const entry = this.formatter({
|
||||
envelope,
|
||||
context,
|
||||
bodyPreview: this.bodyPreview(envelope),
|
||||
});
|
||||
if (this.bus !== undefined) {
|
||||
this.bus.emit("middleware:logged", {
|
||||
middlewareId: this.id,
|
||||
direction: context.direction,
|
||||
messageId: envelope.id,
|
||||
messageType: envelope.type,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private bodyPreview(envelope: MessageEnvelope): string | null {
|
||||
if (!this.includeBody) return null;
|
||||
let serialized: string;
|
||||
try {
|
||||
// `JSON.stringify` returns `undefined` for `function` /
|
||||
// `symbol` / standalone `undefined` payloads — coerce to an
|
||||
// empty string so the size check below has a real value.
|
||||
serialized = JSON.stringify(envelope.payload) ?? "";
|
||||
} catch {
|
||||
serialized = String(envelope.payload);
|
||||
}
|
||||
if (serialized.length <= this.maxBodyChars) return serialized;
|
||||
return `${serialized.slice(0, this.maxBodyChars)}…`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LoggingMiddleware } from "./logging-middleware.js";
|
||||
|
||||
const env = (payload: unknown) =>
|
||||
createEnvelope({
|
||||
type: "log:test",
|
||||
senderId: "peer-1",
|
||||
payload,
|
||||
});
|
||||
|
||||
const ctx = (
|
||||
direction: "incoming" | "outgoing"
|
||||
): {
|
||||
direction: "incoming" | "outgoing";
|
||||
state: Record<string, unknown>;
|
||||
} => ({ direction, state: {} });
|
||||
|
||||
describe("LoggingMiddleware: body inclusion, formatting, max size", () => {
|
||||
it("does not modify the envelope and never drops", () => {
|
||||
const logger = new LoggingMiddleware();
|
||||
const e = env({ ok: true });
|
||||
expect(logger.apply(e, ctx("outgoing"))).toBe(e);
|
||||
});
|
||||
|
||||
it("uses default formatter and emits to bus when configured", () => {
|
||||
const events: unknown[] = [];
|
||||
const logger = new LoggingMiddleware({
|
||||
includeBody: true,
|
||||
bus: {
|
||||
emit: (_event, payload) => events.push(payload),
|
||||
},
|
||||
});
|
||||
logger.apply(env({ hello: "world" }), ctx("outgoing"));
|
||||
expect(events.length).toBe(1);
|
||||
});
|
||||
|
||||
it("body inclusion off → bodyPreview is null", () => {
|
||||
let captured: { bodyPreview: string | null } | null = null;
|
||||
const logger = new LoggingMiddleware({
|
||||
includeBody: false,
|
||||
formatter: ({ bodyPreview }) => {
|
||||
captured = { bodyPreview };
|
||||
return bodyPreview;
|
||||
},
|
||||
});
|
||||
logger.apply(env({ secret: "x" }), ctx("outgoing"));
|
||||
expect(captured?.bodyPreview).toBeNull();
|
||||
});
|
||||
|
||||
it("clips body past maxBodyChars with an ellipsis", () => {
|
||||
let captured: string | null = null;
|
||||
const logger = new LoggingMiddleware({
|
||||
includeBody: true,
|
||||
maxBodyChars: 10,
|
||||
formatter: ({ bodyPreview }) => {
|
||||
captured = bodyPreview;
|
||||
return bodyPreview;
|
||||
},
|
||||
});
|
||||
const long = "x".repeat(200);
|
||||
logger.apply(env(long), ctx("outgoing"));
|
||||
expect(captured).not.toBeNull();
|
||||
expect((captured as unknown as string).endsWith("…")).toBe(true);
|
||||
expect((captured as unknown as string).length).toBe(11); // 10 chars + ellipsis
|
||||
});
|
||||
|
||||
it("falls back to String(payload) when JSON.stringify throws", () => {
|
||||
let captured: string | null = null;
|
||||
const logger = new LoggingMiddleware({
|
||||
includeBody: true,
|
||||
formatter: ({ bodyPreview }) => {
|
||||
captured = bodyPreview;
|
||||
return bodyPreview;
|
||||
},
|
||||
});
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic["self"] = cyclic;
|
||||
logger.apply(env(cyclic), ctx("outgoing"));
|
||||
expect(captured).toContain("[object");
|
||||
});
|
||||
|
||||
it("does not throw when no bus is configured", () => {
|
||||
const logger = new LoggingMiddleware({ includeBody: true });
|
||||
expect(() =>
|
||||
logger.apply(env({ hello: "world" }), ctx("incoming"))
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("coerces undefined-stringify payloads (functions / symbols) to ''", () => {
|
||||
let captured: string | null = "sentinel";
|
||||
const logger = new LoggingMiddleware({
|
||||
includeBody: true,
|
||||
formatter: ({ bodyPreview }) => {
|
||||
captured = bodyPreview;
|
||||
return bodyPreview;
|
||||
},
|
||||
});
|
||||
// `JSON.stringify(() => 0)` returns `undefined` — the middleware
|
||||
// coerces to an empty string so the size check still has a value.
|
||||
const tampered = { ...env("x"), payload: () => 0 };
|
||||
logger.apply(tampered, ctx("outgoing"));
|
||||
expect(captured).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
|
||||
import type { Middleware } from "../types/middleware.types.js";
|
||||
import { MiddlewareManager, priorityForBand } from "./middleware-manager.js";
|
||||
|
||||
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
|
||||
createEnvelope({
|
||||
type: "test",
|
||||
senderId: "peer-1",
|
||||
payload: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("NET-41 — MiddlewareManager priority bands", () => {
|
||||
it("priorityForBand maps numeric priorities to bands", () => {
|
||||
expect(priorityForBand(0)).toBe("CRITICAL");
|
||||
expect(priorityForBand(99)).toBe("CRITICAL");
|
||||
expect(priorityForBand(150)).toBe("HIGH");
|
||||
expect(priorityForBand(250)).toBe("NORMAL");
|
||||
expect(priorityForBand(350)).toBe("LOW");
|
||||
expect(priorityForBand(1_000)).toBe("BACKGROUND");
|
||||
});
|
||||
|
||||
it("runs middlewares in priority order", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
const order: string[] = [];
|
||||
const make = (id: string, priority: number): Middleware => ({
|
||||
id,
|
||||
priority,
|
||||
apply: (e) => {
|
||||
order.push(id);
|
||||
return e;
|
||||
},
|
||||
});
|
||||
manager.register(make("background", 410));
|
||||
manager.register(make("critical", 50));
|
||||
manager.register(make("normal", 250));
|
||||
manager.register(make("high", 150));
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect(order).toEqual(["critical", "high", "normal", "background"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NET-41 — register / unregister / enable / disable", () => {
|
||||
it("rejects duplicate registration", () => {
|
||||
const manager = new MiddlewareManager();
|
||||
const m: Middleware = { id: "x", priority: 100, apply: (e) => e };
|
||||
manager.register(m);
|
||||
try {
|
||||
manager.register(m);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ALREADY_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unregister missing throws MIDDLEWARE_NOT_REGISTERED", () => {
|
||||
const manager = new MiddlewareManager();
|
||||
try {
|
||||
manager.unregister("nope");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_NOT_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("enable / disable on missing id throws", () => {
|
||||
const manager = new MiddlewareManager();
|
||||
expect(() => manager.enable("nope")).toThrow();
|
||||
expect(() => manager.disable("nope")).toThrow();
|
||||
});
|
||||
|
||||
it("disabled middleware is skipped from the chain", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
const seen: string[] = [];
|
||||
manager.register({
|
||||
id: "a",
|
||||
priority: 100,
|
||||
apply: (e) => {
|
||||
seen.push("a");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
manager.register({
|
||||
id: "b",
|
||||
priority: 200,
|
||||
apply: (e) => {
|
||||
seen.push("b");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
manager.disable("a");
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect(seen).toEqual(["b"]);
|
||||
manager.enable("a");
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect(seen).toEqual(["b", "a", "b"]);
|
||||
});
|
||||
|
||||
it("respects per-direction filter", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
const seen: string[] = [];
|
||||
manager.register({
|
||||
id: "in-only",
|
||||
priority: 100,
|
||||
direction: "incoming",
|
||||
apply: (e) => {
|
||||
seen.push("in-only");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
manager.register({
|
||||
id: "out-only",
|
||||
priority: 100,
|
||||
direction: "outgoing",
|
||||
apply: (e) => {
|
||||
seen.push("out-only");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
manager.register({
|
||||
id: "both",
|
||||
priority: 200,
|
||||
apply: (e) => {
|
||||
seen.push("both");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect(seen).toEqual(["out-only", "both"]);
|
||||
seen.length = 0;
|
||||
await manager.dispatch(env(), "incoming");
|
||||
expect(seen).toEqual(["in-only", "both"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NET-41 — chain-result + error policies", () => {
|
||||
it("returns dropped: true when a middleware returns null", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register({ id: "a", priority: 100, apply: (e) => e });
|
||||
manager.register({
|
||||
id: "drop",
|
||||
priority: 200,
|
||||
apply: () => null,
|
||||
});
|
||||
manager.register({ id: "z", priority: 300, apply: (e) => e });
|
||||
const result = await manager.dispatch(env(), "outgoing");
|
||||
expect(result.dropped).toBe(true);
|
||||
expect(result.envelope).toBeNull();
|
||||
expect(result.droppedBy).toBe("drop");
|
||||
expect(result.executed).toEqual(["a", "drop"]);
|
||||
});
|
||||
|
||||
it("error-policy: stop wraps as MIDDLEWARE_ERROR", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register({
|
||||
id: "boom",
|
||||
priority: 100,
|
||||
apply: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("error-policy: continue swallows and forwards the envelope", async () => {
|
||||
const manager = new MiddlewareManager({
|
||||
errorPolicy: { policy: "continue" },
|
||||
});
|
||||
const seen: string[] = [];
|
||||
manager.register({
|
||||
id: "boom",
|
||||
priority: 100,
|
||||
apply: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
});
|
||||
manager.register({
|
||||
id: "after",
|
||||
priority: 200,
|
||||
apply: (e) => {
|
||||
seen.push("after");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
const result = await manager.dispatch(env(), "outgoing");
|
||||
expect(result.dropped).toBe(false);
|
||||
expect(seen).toEqual(["after"]);
|
||||
});
|
||||
|
||||
it("error-policy: retry recovers when the next attempt succeeds", async () => {
|
||||
const manager = new MiddlewareManager({
|
||||
errorPolicy: { policy: "retry", maxRetries: 3 },
|
||||
});
|
||||
let calls = 0;
|
||||
manager.register({
|
||||
id: "flaky",
|
||||
priority: 100,
|
||||
apply: (e) => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error("transient");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
const result = await manager.dispatch(env(), "outgoing");
|
||||
expect(result.dropped).toBe(false);
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("error-policy: retry exhausted bubbles MIDDLEWARE_ERROR", async () => {
|
||||
const manager = new MiddlewareManager({
|
||||
errorPolicy: { policy: "retry", maxRetries: 2 },
|
||||
});
|
||||
manager.register({
|
||||
id: "always-fails",
|
||||
priority: 100,
|
||||
apply: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("retry honours minimum 1 attempt when maxRetries is omitted", async () => {
|
||||
const manager = new MiddlewareManager({
|
||||
errorPolicy: { policy: "retry" },
|
||||
});
|
||||
let calls = 0;
|
||||
manager.register({
|
||||
id: "fails-once",
|
||||
priority: 100,
|
||||
apply: () => {
|
||||
calls += 1;
|
||||
throw new Error("nope");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect.fail("should have thrown");
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves non-Error throws (no cause attached) under stop policy", async () => {
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register({
|
||||
id: "weird",
|
||||
priority: 100,
|
||||
apply: () => {
|
||||
const literal: unknown = "string-error";
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
try {
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ERROR
|
||||
);
|
||||
expect((err as NetworkMwError).cause).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves non-Error throws under retry exhaustion", async () => {
|
||||
const manager = new MiddlewareManager({
|
||||
errorPolicy: { policy: "retry", maxRetries: 2 },
|
||||
});
|
||||
manager.register({
|
||||
id: "weird-retry",
|
||||
priority: 100,
|
||||
apply: () => {
|
||||
const literal: unknown = "string-error";
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
try {
|
||||
await manager.dispatch(env(), "outgoing");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ERROR
|
||||
);
|
||||
expect((err as NetworkMwError).cause).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("list / has / chain reflect the registered set", () => {
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register({
|
||||
id: "in",
|
||||
priority: 100,
|
||||
direction: "incoming",
|
||||
apply: (e) => e,
|
||||
});
|
||||
manager.register({ id: "any", priority: 200, apply: (e) => e });
|
||||
expect(manager.has("any")).toBe(true);
|
||||
expect(manager.list().map((m) => m.id)).toEqual(["in", "any"]);
|
||||
expect(manager.chain("outgoing").map((m) => m.id)).toEqual(["any"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
# `MiddlewareManager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the registered middleware list and runs both inbound and outbound chains in priority order for every dispatch. Pins the priority bands defined in `NET-41` (CRITICAL `[0,99]`, HIGH `[100,199]`, NORMAL `[200,299]`, LOW `[300,399]`, BACKGROUND `[400,∞)`) and handles the three error policies (`continue`, `stop`, `retry`) so individual middlewares stay narrow.
|
||||
|
||||
## Features
|
||||
|
||||
- `register` / `unregister` / `enable` / `disable` for runtime mutation without restart.
|
||||
- Per-direction chain: `direction: undefined` means "run on both"; otherwise the middleware only runs for the matching direction.
|
||||
- `chain(direction)` returns the ordered, enabled list a dispatch would run; useful for debugging and tests.
|
||||
- `dispatch(envelope, direction)` returns a `ChainResult` so callers branch on `dropped: true` instead of inspecting envelope state.
|
||||
- `priorityForBand(priority)` exported as a free function so callers asking "what band does this priority belong to?" do not need a manager instance.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MiddlewareManager,
|
||||
type Middleware,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register({
|
||||
id: "tag",
|
||||
priority: 250,
|
||||
apply: (env) => ({
|
||||
...env,
|
||||
headers: { ...env.headers, "x-tagged": "yes" },
|
||||
}),
|
||||
} satisfies Middleware);
|
||||
|
||||
const result = await manager.dispatch(envelope, "outgoing");
|
||||
result.dropped; // false
|
||||
result.envelope?.headers["x-tagged"]; // "yes"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MiddlewareManager,
|
||||
priorityForBand,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager({
|
||||
errorPolicy: { policy: "retry", maxRetries: 3 },
|
||||
});
|
||||
|
||||
manager.register({
|
||||
id: "validate",
|
||||
priority: 50, // CRITICAL band
|
||||
direction: "incoming",
|
||||
apply: async (env) => (env.payload === undefined ? null : env),
|
||||
});
|
||||
|
||||
manager.register({
|
||||
id: "metrics",
|
||||
priority: 410, // BACKGROUND band
|
||||
apply: async (env) => env,
|
||||
});
|
||||
|
||||
priorityForBand(50); // "CRITICAL"
|
||||
priorityForBand(410); // "BACKGROUND"
|
||||
|
||||
manager.disable("metrics");
|
||||
const result = await manager.dispatch(envelope, "incoming");
|
||||
if (result.dropped) {
|
||||
console.warn("dropped by", result.droppedBy);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MiddlewareManager(config?)`
|
||||
- `register(middleware: Middleware): void` — throws `MIDDLEWARE_ALREADY_REGISTERED` on duplicate id.
|
||||
- `unregister(id: string): void` — throws `MIDDLEWARE_NOT_REGISTERED` if missing.
|
||||
- `enable(id) / disable(id): void` — throws `MIDDLEWARE_NOT_REGISTERED` if missing.
|
||||
- `has(id): boolean`.
|
||||
- `list(): ReadonlyArray<Middleware>`.
|
||||
- `chain(direction): ReadonlyArray<Middleware>` — ordered, enabled, direction-filtered list.
|
||||
- `dispatch(envelope, direction): Promise<ChainResult>`.
|
||||
- `function priorityForBand(priority: number): MiddlewarePriority`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Middlewares are stored in a `Map` keyed by id. Re-`register` is rejected so callers cannot accidentally clobber an existing middleware silently — `unregister` first if you mean to replace.
|
||||
- The chain runs synchronously in priority order. Async middlewares add one micro-task per call but preserve ordering across awaits.
|
||||
- Error handling goes through `runOne`: `continue` swallows and forwards the un-mutated envelope, `stop` rethrows as `MIDDLEWARE_ERROR` with the original as `cause`, `retry` re-invokes up to `maxRetries` before falling back to `stop`. The chain itself never silently drops because of an exception — callers always learn what happened.
|
||||
- `ChainResult.executed` is frozen so consumers cannot mutate the audit trail. `envelope` is `null` exactly when `dropped: true`, so the type forces explicit handling.
|
||||
- Outbound and inbound chains share state (the `state` bag is per-dispatch, not per-direction) but execute independently so a slow `incoming` dispatch never blocks an `outgoing` send.
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
|
||||
import type { ChainResult, ErrorPolicyConfig } from "../types/chain.types.js";
|
||||
import type {
|
||||
ChainDirection,
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
MiddlewarePriority,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
/**
|
||||
* Numeric range a band claims. The manager rejects priorities outside
|
||||
* `[0, ∞)` and slots middleware into bands by simple comparison.
|
||||
*/
|
||||
// Band boundaries are encoded directly in `priorityForBand` to avoid a
|
||||
// declarative table that would force a defensive fallback branch the
|
||||
// coverage gate cannot reach.
|
||||
|
||||
/**
|
||||
* Resolve the band a numeric priority belongs to.
|
||||
*/
|
||||
export function priorityForBand(priority: number): MiddlewarePriority {
|
||||
if (priority < 100) return "CRITICAL";
|
||||
if (priority < 200) return "HIGH";
|
||||
if (priority < 300) return "NORMAL";
|
||||
if (priority < 400) return "LOW";
|
||||
return "BACKGROUND";
|
||||
}
|
||||
|
||||
/**
|
||||
* The manager owns the registered middleware list, runs the chain in
|
||||
* priority order for both directions, applies the configured error
|
||||
* policy, and returns a `ChainResult` per dispatch so callers can act
|
||||
* on drops without inspecting envelope state.
|
||||
*/
|
||||
export class MiddlewareManager {
|
||||
private readonly middlewares = new Map<string, Middleware>();
|
||||
private readonly enabledOverrides = new Map<string, boolean>();
|
||||
private readonly errorPolicy: ErrorPolicyConfig;
|
||||
|
||||
public constructor(config: { errorPolicy?: ErrorPolicyConfig } = {}) {
|
||||
this.errorPolicy = config.errorPolicy ?? { policy: "stop" };
|
||||
}
|
||||
|
||||
public register(middleware: Middleware): void {
|
||||
if (this.middlewares.has(middleware.id)) {
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ALREADY_REGISTERED,
|
||||
"manager.duplicate",
|
||||
{ context: { id: middleware.id } }
|
||||
);
|
||||
}
|
||||
this.middlewares.set(middleware.id, middleware);
|
||||
}
|
||||
|
||||
public unregister(id: string): void {
|
||||
if (!this.middlewares.delete(id)) {
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_NOT_REGISTERED,
|
||||
"manager.missing",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
this.enabledOverrides.delete(id);
|
||||
}
|
||||
|
||||
public enable(id: string): void {
|
||||
this.requireMiddleware(id);
|
||||
this.enabledOverrides.set(id, true);
|
||||
}
|
||||
|
||||
public disable(id: string): void {
|
||||
this.requireMiddleware(id);
|
||||
this.enabledOverrides.set(id, false);
|
||||
}
|
||||
|
||||
public has(id: string): boolean {
|
||||
return this.middlewares.has(id);
|
||||
}
|
||||
|
||||
public list(): ReadonlyArray<Middleware> {
|
||||
return Array.from(this.middlewares.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of middlewares the manager will run for the given
|
||||
* direction. Sorted by ascending priority (CRITICAL first).
|
||||
*/
|
||||
public chain(direction: ChainDirection): ReadonlyArray<Middleware> {
|
||||
return this.list()
|
||||
.filter((m) => this.isEnabled(m))
|
||||
.filter(
|
||||
(m) => m.direction === undefined || m.direction === direction
|
||||
)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
private isEnabled(middleware: Middleware): boolean {
|
||||
const override = this.enabledOverrides.get(middleware.id);
|
||||
if (override !== undefined) return override;
|
||||
return middleware.enabled !== false;
|
||||
}
|
||||
|
||||
public async dispatch(
|
||||
envelope: MessageEnvelope,
|
||||
direction: ChainDirection
|
||||
): Promise<ChainResult> {
|
||||
const ordered = this.chain(direction);
|
||||
const context: MiddlewareContext = {
|
||||
direction,
|
||||
state: {},
|
||||
};
|
||||
const executed: string[] = [];
|
||||
let current: MessageEnvelope = envelope;
|
||||
for (const middleware of ordered) {
|
||||
const next = await this.runOne(middleware, current, context);
|
||||
executed.push(middleware.id);
|
||||
if (next === null) {
|
||||
return Object.freeze({
|
||||
dropped: true,
|
||||
envelope: null,
|
||||
executed: Object.freeze([...executed]),
|
||||
droppedBy: middleware.id,
|
||||
});
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return Object.freeze({
|
||||
dropped: false,
|
||||
envelope: current,
|
||||
executed: Object.freeze([...executed]),
|
||||
});
|
||||
}
|
||||
|
||||
private async runOne(
|
||||
middleware: Middleware,
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
): Promise<MessageEnvelope | null> {
|
||||
const maxRetries =
|
||||
this.errorPolicy.policy === "retry"
|
||||
? Math.max(1, this.errorPolicy.maxRetries ?? 1)
|
||||
: 1;
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
||||
try {
|
||||
return await middleware.apply(envelope, context);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (this.errorPolicy.policy === "continue") {
|
||||
return envelope;
|
||||
}
|
||||
if (this.errorPolicy.policy === "stop") {
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ERROR,
|
||||
"manager.middleware-threw",
|
||||
{
|
||||
context: { id: middleware.id },
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retry exhausted — surface the last error per the stop strategy.
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_ERROR,
|
||||
"manager.middleware-retries-exhausted",
|
||||
{
|
||||
context: {
|
||||
id: middleware.id,
|
||||
attempts: maxRetries,
|
||||
},
|
||||
cause: lastError instanceof Error ? lastError : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private requireMiddleware(id: string): Middleware {
|
||||
const m = this.middlewares.get(id);
|
||||
if (m === undefined) {
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.MIDDLEWARE_NOT_REGISTERED,
|
||||
"manager.missing",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# `MetricsMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Records per-message metrics — size, processing time, and chain queue wait — and publishes them to a `middleware:metrics` bus topic for `network-plugin-monitor` to aggregate. Sits in the BACKGROUND band by default (`priority: 410`) so it observes the chain after every other middleware has run.
|
||||
|
||||
## Features
|
||||
|
||||
- Approximate UTF-8 size derived from a JSON serialisation of the envelope; falls back to a `String(payload)` form for non-serialisable payloads.
|
||||
- Processing-time / queue-wait deltas derived from a per-dispatch state key (`metrics.enqueuedAt`) so registering the middleware twice — once early, once late — yields `enter` and `exit` measurements that share a baseline.
|
||||
- Optional bus injection so the middleware can be used purely for its side-effects (custom subscriber attached elsewhere).
|
||||
- Injectable clock for deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MetricsMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const samples: unknown[] = [];
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(
|
||||
new MetricsMiddleware({
|
||||
bus: { emit: (_event, m) => samples.push(m) },
|
||||
})
|
||||
);
|
||||
await manager.dispatch(envelope, "outgoing");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MetricsMiddleware,
|
||||
type MessageMetrics,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
let now = 0;
|
||||
const events: MessageMetrics[] = [];
|
||||
const bus = { emit: (_event: string, m: MessageMetrics) => events.push(m) };
|
||||
|
||||
// Two instances: one at the start of the chain (enter), one at the
|
||||
// end (exit). The late instance reports a non-zero `processingTimeMs`
|
||||
// because the early instance seeded `metrics.enqueuedAt`.
|
||||
const enter = new MetricsMiddleware({
|
||||
id: "metrics-enter",
|
||||
priority: 1,
|
||||
bus,
|
||||
clock: () => now,
|
||||
});
|
||||
const exit = new MetricsMiddleware({
|
||||
id: "metrics-exit",
|
||||
priority: 999,
|
||||
bus,
|
||||
clock: () => now,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MetricsMiddleware implements Middleware`
|
||||
- `constructor(config?: MetricsMiddlewareConfig)`.
|
||||
- `apply(envelope, context): MessageEnvelope` — never drops, always emits one metrics event when `bus` is set.
|
||||
- `interface MetricsMiddlewareConfig` — `{ id?, priority?, direction?, bus?, clock? }`.
|
||||
- `interface MessageMetrics` — `{ direction, messageId, messageType, sizeBytes, processingTimeMs, queueWaitMs }`.
|
||||
- `interface MetricsBus` — `{ emit("middleware:metrics", metrics): void }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Size estimation uses a hand-rolled UTF-8 byte counter rather than `TextEncoder` so the middleware works on constrained runtimes (Bun browser, edge workers) without an extra allocation per message. The estimate is exact for the BMP and within a small percentage for surrogate-paired scripts.
|
||||
- The state key `metrics.enqueuedAt` is intentionally namespaced so it does not collide with caller state. Stage-specific keys (e.g. `metrics.enqueuedAt.encryption`) can be layered by configuring `id` accordingly and reading state in a custom subscriber.
|
||||
- The middleware never drops — telemetry should never be the cause of a message vanishing. Failures inside the bus emit propagate as exceptions and are governed by the manager's `error-policy`.
|
||||
- `processingTimeMs` and `queueWaitMs` are equal in the single-instance case because the chain treats the first sample as both the enqueue and the snapshot. Two-instance setups (early enter / late exit) populate them separately.
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export interface MessageMetrics {
|
||||
readonly direction: MiddlewareContext["direction"];
|
||||
readonly messageId: string;
|
||||
readonly messageType: string;
|
||||
/** Approximate envelope size in bytes (UTF-8 of JSON serialization). */
|
||||
readonly sizeBytes: number;
|
||||
/** Wall-clock duration the chain has spent on this message so far. */
|
||||
readonly processingTimeMs: number;
|
||||
/** Time between enqueue (set by metrics on entry) and now. */
|
||||
readonly queueWaitMs: number;
|
||||
}
|
||||
|
||||
export interface MetricsBus {
|
||||
emit(event: "middleware:metrics", metrics: MessageMetrics): void;
|
||||
}
|
||||
|
||||
export interface MetricsMiddlewareConfig {
|
||||
readonly id?: string;
|
||||
readonly priority?: number;
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
readonly bus?: MetricsBus;
|
||||
/** Injectable clock (defaults to `Date.now`). */
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
const ENQUEUED_STATE_KEY = "metrics.enqueuedAt";
|
||||
|
||||
/**
|
||||
* Records per-message metrics — size, processing time so far, and
|
||||
* queue wait between enqueue and current chain step. Publishes a
|
||||
* `middleware:metrics` event on the bus for `network-plugin-monitor`
|
||||
* to aggregate.
|
||||
*
|
||||
* Register this middleware twice if you want to capture both enter
|
||||
* and exit timings: an early instance writes `metrics.enqueuedAt`
|
||||
* into the shared chain state, a late instance computes the deltas
|
||||
* and publishes.
|
||||
*/
|
||||
export class MetricsMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly bus: MetricsBus | undefined;
|
||||
private readonly clock: () => number;
|
||||
|
||||
public constructor(config: MetricsMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "metrics";
|
||||
this.priority = config.priority ?? 410;
|
||||
this.direction = config.direction;
|
||||
this.bus = config.bus;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public apply(
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
): MessageEnvelope {
|
||||
const now = this.clock();
|
||||
const enqueuedAt =
|
||||
(context.state[ENQUEUED_STATE_KEY] as number | undefined) ?? now;
|
||||
if (context.state[ENQUEUED_STATE_KEY] === undefined) {
|
||||
context.state[ENQUEUED_STATE_KEY] = now;
|
||||
}
|
||||
const sizeBytes = approximateSize(envelope);
|
||||
const metrics: MessageMetrics = Object.freeze({
|
||||
direction: context.direction,
|
||||
messageId: envelope.id,
|
||||
messageType: envelope.type,
|
||||
sizeBytes,
|
||||
processingTimeMs: now - enqueuedAt,
|
||||
queueWaitMs: now - enqueuedAt,
|
||||
});
|
||||
this.bus?.emit("middleware:metrics", metrics);
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
|
||||
function approximateSize(envelope: MessageEnvelope): number {
|
||||
let serialized: string;
|
||||
try {
|
||||
// `envelope` is always an object so `JSON.stringify` returns a
|
||||
// string, never `undefined`.
|
||||
serialized = JSON.stringify(envelope);
|
||||
} catch {
|
||||
serialized = String(envelope.payload);
|
||||
}
|
||||
// Cheap UTF-8 byte estimate that avoids allocating a TextEncoder
|
||||
// when the runtime is constrained.
|
||||
let bytes = 0;
|
||||
for (let i = 0; i < serialized.length; i += 1) {
|
||||
const code = serialized.charCodeAt(i);
|
||||
if (code < 0x80) bytes += 1;
|
||||
else if (code < 0x800) bytes += 2;
|
||||
else bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type MessageMetrics,
|
||||
MetricsMiddleware,
|
||||
} from "./metrics-middleware.js";
|
||||
|
||||
const env = (payload: unknown) =>
|
||||
createEnvelope({
|
||||
type: "metric:test",
|
||||
senderId: "peer-1",
|
||||
payload,
|
||||
});
|
||||
|
||||
describe("MetricsMiddleware: size, processing time, queue wait", () => {
|
||||
it("emits a single metrics event per apply", () => {
|
||||
const events: MessageMetrics[] = [];
|
||||
const m = new MetricsMiddleware({
|
||||
bus: { emit: (_event, metrics) => events.push(metrics) },
|
||||
});
|
||||
m.apply(env({ hello: "world" }), {
|
||||
direction: "outgoing",
|
||||
state: {},
|
||||
});
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]?.messageType).toBe("metric:test");
|
||||
expect(events[0]?.sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("two-instance pipeline measures non-zero processing time", async () => {
|
||||
const events: MessageMetrics[] = [];
|
||||
const bus = {
|
||||
emit: (_event: string, metrics: MessageMetrics) =>
|
||||
events.push(metrics),
|
||||
};
|
||||
let now = 100;
|
||||
const enter = new MetricsMiddleware({
|
||||
id: "enter",
|
||||
priority: 1,
|
||||
bus,
|
||||
clock: () => now,
|
||||
});
|
||||
const exit = new MetricsMiddleware({
|
||||
id: "exit",
|
||||
priority: 999,
|
||||
bus,
|
||||
clock: () => now,
|
||||
});
|
||||
const state: Record<string, unknown> = {};
|
||||
enter.apply(env("x"), { direction: "outgoing", state });
|
||||
now += 25;
|
||||
exit.apply(env("x"), { direction: "outgoing", state });
|
||||
expect(events[0]?.processingTimeMs).toBe(0);
|
||||
expect(events[1]?.processingTimeMs).toBe(25);
|
||||
expect(events[1]?.queueWaitMs).toBe(25);
|
||||
});
|
||||
|
||||
it("does not throw when no bus is configured", () => {
|
||||
const m = new MetricsMiddleware();
|
||||
expect(() =>
|
||||
m.apply(env("x"), { direction: "incoming", state: {} })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("size estimator handles unicode characters", () => {
|
||||
const events: MessageMetrics[] = [];
|
||||
const m = new MetricsMiddleware({
|
||||
bus: { emit: (_event, metrics) => events.push(metrics) },
|
||||
});
|
||||
m.apply(env("😀漢字"), { direction: "outgoing", state: {} });
|
||||
// multi-byte chars should push the size above the ASCII baseline
|
||||
expect(events[0]?.sizeBytes).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("size estimator counts 2-byte (Latin Supplement) chars", () => {
|
||||
const events: MessageMetrics[] = [];
|
||||
const m = new MetricsMiddleware({
|
||||
bus: { emit: (_event, metrics) => events.push(metrics) },
|
||||
});
|
||||
// "café" — `é` (U+00E9) lives in the [0x80, 0x800) range and
|
||||
// contributes 2 bytes each.
|
||||
m.apply(env("café"), { direction: "outgoing", state: {} });
|
||||
expect(events[0]?.sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to String(payload) when JSON.stringify throws", () => {
|
||||
const events: MessageMetrics[] = [];
|
||||
const m = new MetricsMiddleware({
|
||||
bus: { emit: (_event, metrics) => events.push(metrics) },
|
||||
});
|
||||
// Build an envelope whose payload is unstringifiable. We bypass
|
||||
// the schema by mutating the field after creation — production
|
||||
// code should not do this; the test exercises the defensive
|
||||
// fallback path.
|
||||
const e = env("x");
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic["self"] = cyclic;
|
||||
const tampered = { ...e, payload: cyclic };
|
||||
m.apply(tampered, { direction: "outgoing", state: {} });
|
||||
expect(events[0]?.sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
# `MiddlewarePlugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single `NetworkPlugin` that owns a `MiddlewareManager` and exposes it as a public field so consumers register, dispatch, and inspect middleware via the manager directly. Keeps the `NetworkPlugin` facade narrow — the manager is the substantive surface; the plugin just gives it a lifecycle hook into a `NetworkNode`.
|
||||
|
||||
## Features
|
||||
|
||||
- `install(node)` records the host node and registers any middlewares passed at construction time (idempotent: pre-existing ids are skipped).
|
||||
- `start` / `stop` / `dispose` lifecycle that satisfies `NetworkPlugin`.
|
||||
- `dispose` unregisters every middleware so a re-installed plugin starts clean.
|
||||
- Public `manager` field gives consumers full access to runtime register / unregister / enable / disable / dispatch.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import {
|
||||
LoggingMiddleware,
|
||||
MiddlewarePlugin,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const bus = new NetworkBus();
|
||||
const node = new NetworkNode({ id: "node-1", networkBus: bus });
|
||||
const plugin = new MiddlewarePlugin({
|
||||
middlewares: [new LoggingMiddleware({ includeBody: true })],
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
|
||||
await plugin.manager.dispatch(envelope, "outgoing");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import {
|
||||
MiddlewarePlugin,
|
||||
ValidationMiddleware,
|
||||
SecurityMiddleware,
|
||||
ThrottlingMiddleware,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const plugin = new MiddlewarePlugin({
|
||||
errorPolicy: { policy: "stop" },
|
||||
middlewares: [
|
||||
new ValidationMiddleware({ schemas: { /* … */ } }),
|
||||
new SecurityMiddleware({ /* … */ }),
|
||||
new ThrottlingMiddleware({
|
||||
global: { capacity: 100, refillRate: 50 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const node = new NetworkNode({
|
||||
id: "node-1",
|
||||
networkBus: new NetworkBus(),
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MiddlewarePlugin implements NetworkPlugin`
|
||||
- `name: "middleware"`.
|
||||
- `manager: MiddlewareManager` — public field; primary surface.
|
||||
- `constructor(config?: MiddlewarePluginConfig)`.
|
||||
- `install(node: NetworkNode): void`.
|
||||
- `start(): void` / `stop(): void` — idempotent.
|
||||
- `dispose(): void` — unregisters every middleware and detaches the node.
|
||||
- `host(): NetworkNode | null` — node currently bound (or `null` after dispose).
|
||||
- `isStarted(): boolean`.
|
||||
- `interface MiddlewarePluginConfig` — `{ errorPolicy?, middlewares? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The plugin holds a _reference_ to the manager rather than re-exporting its methods so the public surface stays explicit. Callers that prefer a thin facade can write one over the public field — keeping the plugin minimal makes refactors cheaper.
|
||||
- Middlewares registered at construction time are only applied on `install` so a plugin built before its host node still satisfies the same contract once attached.
|
||||
- `dispose` walks `manager.list()` and unregisters each middleware. The `has(id)` guard absorbs the (rare) case of an id being removed mid-iteration by an external listener.
|
||||
- `start` / `stop` only flip a boolean: the manager is fully usable without the plugin being started — `start` exists to satisfy `NetworkPlugin` and to give consumers a single hook for "OK, now you may begin processing".
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import { MiddlewareManager } from "../manager/middleware-manager.js";
|
||||
import type { ErrorPolicyConfig } from "../types/chain.types.js";
|
||||
import type { Middleware } from "../types/middleware.types.js";
|
||||
|
||||
export interface MiddlewarePluginConfig {
|
||||
/** Optional error policy applied to every middleware run. */
|
||||
readonly errorPolicy?: ErrorPolicyConfig;
|
||||
/** Middlewares to register at construction time. */
|
||||
readonly middlewares?: ReadonlyArray<Middleware>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `NetworkPlugin` that owns a `MiddlewareManager` and wires its
|
||||
* lifecycle into the host `NetworkNode`. Consumers register
|
||||
* middlewares via the plugin's public `manager` field — keeping the
|
||||
* facade narrow lets the manager evolve without breaking the plugin
|
||||
* contract.
|
||||
*/
|
||||
export class MiddlewarePlugin implements NetworkPlugin {
|
||||
public readonly name = "middleware";
|
||||
public readonly manager: MiddlewareManager;
|
||||
private readonly initialMiddlewares: ReadonlyArray<Middleware>;
|
||||
private node: NetworkNode | null = null;
|
||||
private started = false;
|
||||
|
||||
public constructor(config: MiddlewarePluginConfig = {}) {
|
||||
this.manager = new MiddlewareManager({
|
||||
...(config.errorPolicy === undefined
|
||||
? {}
|
||||
: { errorPolicy: config.errorPolicy }),
|
||||
});
|
||||
this.initialMiddlewares = Object.freeze([
|
||||
...(config.middlewares ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
this.node = node;
|
||||
for (const middleware of this.initialMiddlewares) {
|
||||
if (!this.manager.has(middleware.id)) {
|
||||
this.manager.register(middleware);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
// Snapshot the ids first so the unregister loop does not race
|
||||
// against the underlying iterator.
|
||||
const ids = this.manager.list().map((m) => m.id);
|
||||
for (const id of ids) {
|
||||
this.manager.unregister(id);
|
||||
}
|
||||
this.node = null;
|
||||
}
|
||||
|
||||
public host(): NetworkNode | null {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
public isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createEnvelope, NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LoggingMiddleware } from "../logging/logging-middleware.js";
|
||||
import { MiddlewarePlugin } from "./middleware-plugin.js";
|
||||
|
||||
const env = () =>
|
||||
createEnvelope({ type: "plugin:test", senderId: "peer-1", payload: {} });
|
||||
|
||||
const newNode = () => {
|
||||
const bus = new NetworkBus();
|
||||
return new NetworkNode({ id: "node-1", networkBus: bus });
|
||||
};
|
||||
|
||||
describe("MiddlewarePlugin: NetworkPlugin lifecycle", () => {
|
||||
it("constructs with no middlewares", () => {
|
||||
const plugin = new MiddlewarePlugin();
|
||||
expect(plugin.name).toBe("middleware");
|
||||
expect(plugin.manager.list()).toEqual([]);
|
||||
expect(plugin.host()).toBeNull();
|
||||
expect(plugin.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("registers initial middlewares on install (idempotent)", async () => {
|
||||
const logger = new LoggingMiddleware({ id: "logger-init" });
|
||||
const plugin = new MiddlewarePlugin({ middlewares: [logger] });
|
||||
// Re-installing must not throw on the duplicate id.
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.manager.has("logger-init")).toBe(true);
|
||||
expect(plugin.host()).not.toBeNull();
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("start / stop toggle isStarted", async () => {
|
||||
const plugin = new MiddlewarePlugin();
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.isStarted()).toBe(true);
|
||||
await node.stop();
|
||||
expect(plugin.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("dispose unregisters every middleware and detaches the node", async () => {
|
||||
const plugin = new MiddlewarePlugin({
|
||||
middlewares: [new LoggingMiddleware({ id: "to-clean" })],
|
||||
});
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.manager.has("to-clean")).toBe(true);
|
||||
await node.stop();
|
||||
plugin.dispose();
|
||||
expect(plugin.manager.list().length).toBe(0);
|
||||
expect(plugin.host()).toBeNull();
|
||||
});
|
||||
|
||||
it("manager.dispatch runs registered middlewares end-to-end", async () => {
|
||||
const events: unknown[] = [];
|
||||
const plugin = new MiddlewarePlugin({
|
||||
middlewares: [
|
||||
new LoggingMiddleware({
|
||||
id: "wired-logger",
|
||||
bus: { emit: (_event, payload) => events.push(payload) },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
await plugin.manager.dispatch(env(), "outgoing");
|
||||
await node.stop();
|
||||
expect(events.length).toBe(1);
|
||||
});
|
||||
|
||||
it("custom error policy is honoured", async () => {
|
||||
const plugin = new MiddlewarePlugin({
|
||||
errorPolicy: { policy: "continue" },
|
||||
middlewares: [
|
||||
{
|
||||
id: "boom",
|
||||
priority: 10,
|
||||
apply: () => {
|
||||
throw new Error("noisy");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "after",
|
||||
priority: 20,
|
||||
apply: (e) => e,
|
||||
},
|
||||
],
|
||||
});
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
const result = await plugin.manager.dispatch(env(), "outgoing");
|
||||
await node.stop();
|
||||
expect(result.dropped).toBe(false);
|
||||
});
|
||||
|
||||
it("install skips initial middlewares whose id is already registered", async () => {
|
||||
const dup = new LoggingMiddleware({ id: "dup" });
|
||||
const plugin = new MiddlewarePlugin({ middlewares: [dup] });
|
||||
// Pre-register manually so the install loop hits the
|
||||
// already-registered branch without throwing.
|
||||
plugin.manager.register(new LoggingMiddleware({ id: "dup" }));
|
||||
const node = newNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.manager.has("dup")).toBe(true);
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("dispose is a no-op when no middlewares are registered", () => {
|
||||
const plugin = new MiddlewarePlugin();
|
||||
expect(() => plugin.dispose()).not.toThrow();
|
||||
expect(plugin.host()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
# `SecurityMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Applies sign / encrypt on outbound messages and verifies signatures on inbound messages, gated by a per-`MessageType` or per-tier policy. Crypto-implementation-agnostic: callers supply `sign`, `encrypt`, and `verify` callbacks so the middleware works with any crypto module today (and will plug into `@suaveplan/network-plugin-crypto-messages` once that package ships).
|
||||
|
||||
## Features
|
||||
|
||||
- Two-key lookup: policy is checked first by `MessageType`, then by tier name resolved from a caller-supplied `tierFor(peerId)` lookup. The first match wins.
|
||||
- `requireSignature: true` rejects unsigned/malformed inbound messages with `SECURITY_REJECTED`.
|
||||
- Outbound `sign` runs before `encrypt` so the verifier sees the signature inside the encrypted envelope.
|
||||
- Misconfiguration (policy demands signing without supplying a `sign` callback) is detected at dispatch time, not runtime, so deployments fail loudly.
|
||||
- Lives in CRITICAL band by default (`priority: 80`) so security runs immediately after validation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SecurityMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(
|
||||
new SecurityMiddleware({
|
||||
policy: {
|
||||
"registry:add": { sign: true, requireSignature: true },
|
||||
},
|
||||
sign: async (env) => ({
|
||||
...env,
|
||||
headers: { ...env.headers, "x-signature": "sig-stub" },
|
||||
}),
|
||||
verify: async (env) => env.headers["x-signature"] === "sig-stub",
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SecurityMiddleware,
|
||||
type SecurityPolicy,
|
||||
type TierName,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const policy: SecurityPolicy = {
|
||||
"tier-0": { sign: true, encrypt: true, requireSignature: true },
|
||||
"tier-1": { sign: true, requireSignature: true },
|
||||
"tier-2": { sign: true },
|
||||
"registry:add": { sign: true, requireSignature: true },
|
||||
};
|
||||
|
||||
const security = new SecurityMiddleware({
|
||||
id: "tier-aware-security",
|
||||
priority: 90,
|
||||
policy,
|
||||
tierFor: (peerId): TierName | undefined =>
|
||||
peerId.startsWith("hub-") ? "tier-0" : "tier-2",
|
||||
sign: signWithEd25519,
|
||||
encrypt: encryptWithChacha,
|
||||
verify: verifyEd25519,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SecurityMiddleware implements Middleware`
|
||||
- `constructor(config?: SecurityMiddlewareConfig)`.
|
||||
- `apply(envelope, context): Promise<MessageEnvelope | null>`.
|
||||
- `interface SecurityMiddlewareConfig` — `{ id?, priority?, direction?, tierFor?, policy?, sign?, encrypt?, verify? }`.
|
||||
- `interface SecurityPolicyEntry` — `{ sign?, encrypt?, requireSignature? }`.
|
||||
- `type SecurityPolicy` — `Readonly<Record<string, SecurityPolicyEntry | undefined>>` keyed by `MessageType` or `TierName`.
|
||||
- `type TierName` — `"tier-0" | "tier-1" | "tier-2" | "tier-3"`.
|
||||
- `type SignFn` / `EncryptFn` / `VerifyFn` — callback signatures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `MessageType` lookup wins over the tier lookup so deployments can opt specific message types out of (or into) more restrictive policies without rewriting the tier table.
|
||||
- `tierFor` resolves against `envelope.targetId ?? envelope.senderId` so outbound messages key off the recipient and inbound messages key off the originator. Callers that need different lookup semantics can compose two SecurityMiddleware instances under different ids.
|
||||
- Crypto callbacks MAY be sync or async; the middleware always awaits, so adding async-only flows (e.g. WebCrypto) does not require recompiling sync paths.
|
||||
- The error code is deliberately generic (`SECURITY_REJECTED`) — distinguishing missing-callback vs invalid-signature is left to the `messageKey` so callers can branch on the key string when richer feedback is required.
|
||||
- Policy entries default to "no action" — `sign: false` and `requireSignature: false` are equivalent to omitting the keys, matching the proposal's "additive only" stance.
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export type TierName = "tier-0" | "tier-1" | "tier-2" | "tier-3";
|
||||
|
||||
export interface SecurityPolicyEntry {
|
||||
/** Whether outbound messages of this type/tier should be signed. */
|
||||
readonly sign?: boolean;
|
||||
/** Whether outbound messages of this type/tier should be encrypted. */
|
||||
readonly encrypt?: boolean;
|
||||
/** Whether inbound messages MUST be signed; reject otherwise. */
|
||||
readonly requireSignature?: boolean;
|
||||
}
|
||||
|
||||
export type SecurityPolicy = Readonly<
|
||||
Record<string, SecurityPolicyEntry | undefined>
|
||||
>;
|
||||
|
||||
export type SignFn = (
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
) => MessageEnvelope | Promise<MessageEnvelope>;
|
||||
|
||||
export type EncryptFn = SignFn;
|
||||
|
||||
export type VerifyFn = (
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export interface SecurityMiddlewareConfig {
|
||||
readonly id?: string;
|
||||
readonly priority?: number;
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
/**
|
||||
* Lookup helper that maps a `peerId` to the tier name used in
|
||||
* `policy`. Returning `undefined` means "no tier-specific entry".
|
||||
*/
|
||||
readonly tierFor?: (peerId: string) => TierName | undefined;
|
||||
/**
|
||||
* Policy keyed by either `MessageType` or `TierName` — the
|
||||
* middleware checks both, message type taking precedence.
|
||||
*/
|
||||
readonly policy?: SecurityPolicy;
|
||||
/** Required when any policy entry sets `sign: true`. */
|
||||
readonly sign?: SignFn;
|
||||
/** Required when any policy entry sets `encrypt: true`. */
|
||||
readonly encrypt?: EncryptFn;
|
||||
/** Required when any policy entry sets `requireSignature: true`. */
|
||||
readonly verify?: VerifyFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies sign / encrypt / verify per policy. The middleware is
|
||||
* crypto-implementation-agnostic: callers supply `sign`, `encrypt`,
|
||||
* and `verify` callbacks (typically backed by
|
||||
* `@suaveplan/network-plugin-crypto-messages` once shipped, or by an
|
||||
* in-house crypto module today).
|
||||
*/
|
||||
export class SecurityMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly policy: SecurityPolicy;
|
||||
private readonly tierFor:
|
||||
| ((peerId: string) => TierName | undefined)
|
||||
| undefined;
|
||||
private readonly signFn: SignFn | undefined;
|
||||
private readonly encryptFn: EncryptFn | undefined;
|
||||
private readonly verifyFn: VerifyFn | undefined;
|
||||
|
||||
public constructor(config: SecurityMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "security";
|
||||
this.priority = config.priority ?? 80;
|
||||
this.direction = config.direction;
|
||||
this.policy = config.policy ?? {};
|
||||
this.tierFor = config.tierFor;
|
||||
this.signFn = config.sign;
|
||||
this.encryptFn = config.encrypt;
|
||||
this.verifyFn = config.verify;
|
||||
}
|
||||
|
||||
public async apply(
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
): Promise<MessageEnvelope | null> {
|
||||
const entry = this.entryFor(envelope);
|
||||
if (entry === undefined) return envelope;
|
||||
if (context.direction === "outgoing") {
|
||||
return await this.handleOutgoing(envelope, context, entry);
|
||||
}
|
||||
return await this.handleIncoming(envelope, context, entry);
|
||||
}
|
||||
|
||||
private async handleOutgoing(
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext,
|
||||
entry: SecurityPolicyEntry
|
||||
): Promise<MessageEnvelope> {
|
||||
let current = envelope;
|
||||
if (entry.sign === true) {
|
||||
if (this.signFn === undefined) {
|
||||
throw this.misconfigured("security.sign-fn-missing");
|
||||
}
|
||||
current = await this.signFn(current, context);
|
||||
}
|
||||
if (entry.encrypt === true) {
|
||||
if (this.encryptFn === undefined) {
|
||||
throw this.misconfigured("security.encrypt-fn-missing");
|
||||
}
|
||||
current = await this.encryptFn(current, context);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private async handleIncoming(
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext,
|
||||
entry: SecurityPolicyEntry
|
||||
): Promise<MessageEnvelope> {
|
||||
if (entry.requireSignature !== true) return envelope;
|
||||
if (this.verifyFn === undefined) {
|
||||
throw this.misconfigured("security.verify-fn-missing");
|
||||
}
|
||||
const ok = await this.verifyFn(envelope, context);
|
||||
if (!ok) {
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.SECURITY_REJECTED,
|
||||
"security.signature-invalid",
|
||||
{
|
||||
context: {
|
||||
messageId: envelope.id,
|
||||
messageType: envelope.type,
|
||||
senderId: envelope.senderId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private entryFor(
|
||||
envelope: MessageEnvelope
|
||||
): SecurityPolicyEntry | undefined {
|
||||
const byType = this.policy[envelope.type];
|
||||
if (byType !== undefined) return byType;
|
||||
const peerId = envelope.targetId ?? envelope.senderId;
|
||||
const tier = this.tierFor?.(peerId);
|
||||
if (tier === undefined) return undefined;
|
||||
return this.policy[tier];
|
||||
}
|
||||
|
||||
private misconfigured(messageKey: string): NetworkMwError {
|
||||
return new NetworkMwError(
|
||||
NETWORK_MW_CODES.SECURITY_REJECTED,
|
||||
messageKey
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
|
||||
import { SecurityMiddleware } from "./security-middleware.js";
|
||||
|
||||
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
|
||||
createEnvelope({
|
||||
type: "secure:test",
|
||||
senderId: "peer-1",
|
||||
payload: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("SecurityMiddleware: per-policy sign / encrypt / verify", () => {
|
||||
it("signs outbound when policy says so", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { sign: true } },
|
||||
sign: async (e) => ({
|
||||
...e,
|
||||
headers: { ...e.headers, "x-signature": "sig" },
|
||||
}),
|
||||
});
|
||||
const result = await s.apply(env(), {
|
||||
direction: "outgoing",
|
||||
state: {},
|
||||
});
|
||||
expect(result?.headers["x-signature"]).toBe("sig");
|
||||
});
|
||||
|
||||
it("throws SECURITY_REJECTED when sign callback is missing", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { sign: true } },
|
||||
});
|
||||
try {
|
||||
await s.apply(env(), { direction: "outgoing", state: {} });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.SECURITY_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("encrypts after signing on outbound", async () => {
|
||||
const order: string[] = [];
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { sign: true, encrypt: true } },
|
||||
sign: async (e) => {
|
||||
order.push("sign");
|
||||
return e;
|
||||
},
|
||||
encrypt: async (e) => {
|
||||
order.push("encrypt");
|
||||
return e;
|
||||
},
|
||||
});
|
||||
await s.apply(env(), { direction: "outgoing", state: {} });
|
||||
expect(order).toEqual(["sign", "encrypt"]);
|
||||
});
|
||||
|
||||
it("rejects unsigned inbound when requireSignature", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { requireSignature: true } },
|
||||
verify: async () => false,
|
||||
});
|
||||
try {
|
||||
await s.apply(env(), { direction: "incoming", state: {} });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.SECURITY_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires verify callback when requireSignature is set", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { requireSignature: true } },
|
||||
});
|
||||
try {
|
||||
await s.apply(env(), { direction: "incoming", state: {} });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.SECURITY_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards inbound when verify returns true", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { requireSignature: true } },
|
||||
verify: async () => true,
|
||||
});
|
||||
const e = env();
|
||||
expect(await s.apply(e, { direction: "incoming", state: {} })).toBe(e);
|
||||
});
|
||||
|
||||
it("encrypt callback missing throws SECURITY_REJECTED", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { sign: true, encrypt: true } },
|
||||
sign: async (e) => e,
|
||||
});
|
||||
try {
|
||||
await s.apply(env(), { direction: "outgoing", state: {} });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.SECURITY_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to tier policy when no per-type entry matches", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "tier-0": { sign: true } },
|
||||
tierFor: (peerId) => (peerId === "hub-A" ? "tier-0" : undefined),
|
||||
sign: async (e) => ({
|
||||
...e,
|
||||
headers: { ...e.headers, "x-tier-signed": "yes" },
|
||||
}),
|
||||
});
|
||||
const result = await s.apply(
|
||||
env({ targetId: "hub-A", type: "anything" }),
|
||||
{ direction: "outgoing", state: {} }
|
||||
);
|
||||
expect(result?.headers["x-tier-signed"]).toBe("yes");
|
||||
});
|
||||
|
||||
it("returns the envelope when no policy entry matches", async () => {
|
||||
const s = new SecurityMiddleware({});
|
||||
const e = env();
|
||||
expect(await s.apply(e, { direction: "outgoing", state: {} })).toBe(e);
|
||||
});
|
||||
|
||||
it("inbound without requireSignature is forwarded", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { sign: true } },
|
||||
sign: async (e) => e,
|
||||
});
|
||||
const e = env();
|
||||
expect(await s.apply(e, { direction: "incoming", state: {} })).toBe(e);
|
||||
});
|
||||
|
||||
it("falls back to senderId when targetId is absent for tier lookup", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "tier-1": { sign: true } },
|
||||
tierFor: (peerId) => (peerId === "peer-X" ? "tier-1" : undefined),
|
||||
sign: async (e) => ({
|
||||
...e,
|
||||
headers: { ...e.headers, "x-from-sender": "yes" },
|
||||
}),
|
||||
});
|
||||
const result = await s.apply(
|
||||
env({ senderId: "peer-X", type: "anything" }),
|
||||
{ direction: "outgoing", state: {} }
|
||||
);
|
||||
expect(result?.headers["x-from-sender"]).toBe("yes");
|
||||
});
|
||||
|
||||
it("default-config constructor (no args) is usable", async () => {
|
||||
const s = new SecurityMiddleware();
|
||||
const e = env();
|
||||
expect(await s.apply(e, { direction: "outgoing", state: {} })).toBe(e);
|
||||
});
|
||||
|
||||
it("encrypt-only outbound (no sign) still encrypts", async () => {
|
||||
const s = new SecurityMiddleware({
|
||||
policy: { "secure:test": { encrypt: true } },
|
||||
encrypt: async (e) => ({
|
||||
...e,
|
||||
headers: { ...e.headers, "x-encrypted": "yes" },
|
||||
}),
|
||||
});
|
||||
const result = await s.apply(env(), {
|
||||
direction: "outgoing",
|
||||
state: {},
|
||||
});
|
||||
expect(result?.headers["x-encrypted"]).toBe("yes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
# `ThrottlingMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Token-bucket rate limiting backed by `@suaveplan/rate-limiter` for the network chain. Three independent layers — global, per-`senderId`, per-`MessageType` — combine conservatively: a message is allowed only when every applicable bucket has tokens. When any bucket refuses, the middleware either throws `THROTTLED` (default) so the manager's error policy decides, or returns `null` for a clean chain drop.
|
||||
|
||||
## Features
|
||||
|
||||
- Three configurable layers, each with its own `capacity` (burst) and `refillRate` (sustained tokens / sec).
|
||||
- Per-peer buckets are created lazily on first sight so the cost is `O(active peers)` rather than `O(known peers)`.
|
||||
- `onExceed: "throw"` / `"drop"` lets callers choose between loud-failure semantics (informational headers, retry policy) and silent suppression (audit-only deployments).
|
||||
- Injectable clock — every internal `TokenBucket` shares the manager's clock so deterministic tests can pin the schedule exactly.
|
||||
- Lives in HIGH band by default (`priority: 150`) so throttling runs after security/validation but before application-level transformation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ThrottlingMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(
|
||||
new ThrottlingMiddleware({
|
||||
global: { capacity: 10, refillRate: 5 },
|
||||
})
|
||||
);
|
||||
await manager.dispatch(envelope, "outgoing");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ThrottlingMiddleware } from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
let now = 0;
|
||||
const throttler = new ThrottlingMiddleware({
|
||||
id: "tiered-throttle",
|
||||
priority: 160,
|
||||
onExceed: "drop",
|
||||
clock: () => now,
|
||||
global: { capacity: 1000, refillRate: 500 },
|
||||
perPeer: { capacity: 100, refillRate: 50 },
|
||||
perType: {
|
||||
"chunk:upload": { capacity: 20, refillRate: 5 },
|
||||
"registry:add": { capacity: 5, refillRate: 1 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ThrottlingMiddleware implements Middleware`
|
||||
- `constructor(config?: ThrottlingMiddlewareConfig)`.
|
||||
- `apply(envelope, context): MessageEnvelope | null` — `null` only when `onExceed: "drop"`.
|
||||
- `interface ThrottleLimit` — `{ refillRate, capacity }`.
|
||||
- `interface ThrottlingMiddlewareConfig` — `{ id?, priority?, direction?, global?, perPeer?, perType?, onExceed?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All three layers must allow the message — this is intentional: the most-restrictive bucket wins. Combining limits any other way (e.g. "any bucket allows") would let a misbehaving peer chew through the global budget unchecked.
|
||||
- Per-peer buckets are not garbage-collected. For long-running processes with high peer churn, attach an external `peer:disconnected` listener and call `forget(senderId)` from a custom subclass that exposes the peer-bucket map; the default behaviour optimises for the common case where the peer set is bounded.
|
||||
- `THROTTLED` carries `retryAfterSec` so callers (the dispatcher, a custom error responder) can communicate `Retry-After` semantics back to the sender.
|
||||
- `onExceed: "drop"` returns `null` from `apply`, which the manager surfaces as `ChainResult.dropped: true` with `droppedBy: "throttler"` — observability gets the throttle event even when the failure is intentionally silent.
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import { TokenBucket } from "@suaveplan/rate-limiter";
|
||||
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export interface ThrottleLimit {
|
||||
/** Sustained tokens per second. */
|
||||
readonly refillRate: number;
|
||||
/** Bucket size — burst allowance. */
|
||||
readonly capacity: number;
|
||||
}
|
||||
|
||||
export interface ThrottlingMiddlewareConfig {
|
||||
readonly id?: string;
|
||||
readonly priority?: number;
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
/** Limit applied to every message regardless of peer / type. */
|
||||
readonly global?: ThrottleLimit;
|
||||
/** Per-`senderId` limit; the same limit applies to each peer independently. */
|
||||
readonly perPeer?: ThrottleLimit;
|
||||
/** Per-`MessageType` limit map. */
|
||||
readonly perType?: Readonly<Record<string, ThrottleLimit>>;
|
||||
/** Action when any bucket refuses a token. Defaults to `"throw"`. */
|
||||
readonly onExceed?: "throw" | "drop";
|
||||
/** Injectable clock — wired into every TokenBucket for determinism. */
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-bucket rate limiting via `@suaveplan/rate-limiter`. Three
|
||||
* independent layers — global, per-peer, per-type — combine
|
||||
* conservatively: a message is allowed only if every applicable
|
||||
* bucket has tokens. When any bucket refuses, the middleware either
|
||||
* throws `THROTTLED` (default) or returns `null` (chain drop).
|
||||
*/
|
||||
export class ThrottlingMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly globalBucket: TokenBucket | null;
|
||||
private readonly perPeerLimit: ThrottleLimit | undefined;
|
||||
private readonly perPeerBuckets = new Map<string, TokenBucket>();
|
||||
private readonly perTypeBuckets: Map<string, TokenBucket>;
|
||||
private readonly onExceed: "throw" | "drop";
|
||||
private readonly clock: () => number;
|
||||
|
||||
public constructor(config: ThrottlingMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "throttler";
|
||||
this.priority = config.priority ?? 150;
|
||||
this.direction = config.direction;
|
||||
this.onExceed = config.onExceed ?? "throw";
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
this.globalBucket =
|
||||
config.global !== undefined ? this.makeBucket(config.global) : null;
|
||||
this.perPeerLimit = config.perPeer;
|
||||
this.perTypeBuckets = new Map();
|
||||
for (const [type, limit] of Object.entries(config.perType ?? {})) {
|
||||
this.perTypeBuckets.set(type, this.makeBucket(limit));
|
||||
}
|
||||
}
|
||||
|
||||
public apply(
|
||||
envelope: MessageEnvelope,
|
||||
_context: MiddlewareContext
|
||||
): MessageEnvelope | null {
|
||||
const buckets = this.bucketsFor(envelope);
|
||||
for (const bucket of buckets) {
|
||||
const result = bucket.consume(1);
|
||||
if (!result.allowed) {
|
||||
// `retryAfter` is always set on `allowed: false` results
|
||||
// by `@suaveplan/rate-limiter`'s `TokenBucket.consume`.
|
||||
return this.refuse(envelope, result.retryAfter as number);
|
||||
}
|
||||
}
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private bucketsFor(envelope: MessageEnvelope): TokenBucket[] {
|
||||
const out: TokenBucket[] = [];
|
||||
if (this.globalBucket !== null) out.push(this.globalBucket);
|
||||
if (this.perPeerLimit !== undefined) {
|
||||
let bucket = this.perPeerBuckets.get(envelope.senderId);
|
||||
if (bucket === undefined) {
|
||||
bucket = this.makeBucket(this.perPeerLimit);
|
||||
this.perPeerBuckets.set(envelope.senderId, bucket);
|
||||
}
|
||||
out.push(bucket);
|
||||
}
|
||||
const typeBucket = this.perTypeBuckets.get(envelope.type);
|
||||
if (typeBucket !== undefined) out.push(typeBucket);
|
||||
return out;
|
||||
}
|
||||
|
||||
private refuse(envelope: MessageEnvelope, retryAfterSec: number): null {
|
||||
if (this.onExceed === "drop") return null;
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.THROTTLED,
|
||||
"throttling.exceeded",
|
||||
{
|
||||
context: {
|
||||
messageId: envelope.id,
|
||||
messageType: envelope.type,
|
||||
senderId: envelope.senderId,
|
||||
retryAfterSec,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private makeBucket(limit: ThrottleLimit): TokenBucket {
|
||||
return new TokenBucket({
|
||||
capacity: limit.capacity,
|
||||
refillRate: limit.refillRate,
|
||||
clock: this.clock,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
|
||||
import { ThrottlingMiddleware } from "./throttling-middleware.js";
|
||||
|
||||
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
|
||||
createEnvelope({
|
||||
type: "test",
|
||||
senderId: "peer-1",
|
||||
payload: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
direction: "outgoing" as const,
|
||||
state: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
describe("ThrottlingMiddleware: global / per-peer / per-type / burst", () => {
|
||||
it("throws THROTTLED past the global capacity", () => {
|
||||
const now = 0;
|
||||
const t = new ThrottlingMiddleware({
|
||||
global: { capacity: 2, refillRate: 1 },
|
||||
clock: () => now,
|
||||
});
|
||||
t.apply(env(), ctx);
|
||||
t.apply(env(), ctx);
|
||||
try {
|
||||
t.apply(env(), ctx);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.THROTTLED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("onExceed: drop returns null instead of throwing", () => {
|
||||
const now = 0;
|
||||
const t = new ThrottlingMiddleware({
|
||||
global: { capacity: 1, refillRate: 1 },
|
||||
clock: () => now,
|
||||
onExceed: "drop",
|
||||
});
|
||||
t.apply(env(), ctx);
|
||||
expect(t.apply(env(), ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("per-peer buckets are independent across senderIds", () => {
|
||||
const now = 0;
|
||||
const t = new ThrottlingMiddleware({
|
||||
perPeer: { capacity: 1, refillRate: 1 },
|
||||
clock: () => now,
|
||||
});
|
||||
t.apply(env({ senderId: "peer-A" }), ctx);
|
||||
t.apply(env({ senderId: "peer-B" }), ctx);
|
||||
try {
|
||||
t.apply(env({ senderId: "peer-A" }), ctx);
|
||||
expect.fail("should have thrown");
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
});
|
||||
|
||||
it("per-type bucket only triggers on matching messages", () => {
|
||||
const now = 0;
|
||||
const t = new ThrottlingMiddleware({
|
||||
perType: { "rare:type": { capacity: 1, refillRate: 1 } },
|
||||
clock: () => now,
|
||||
});
|
||||
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
|
||||
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
|
||||
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
|
||||
t.apply(env({ type: "rare:type", senderId: "peer-1" }), ctx);
|
||||
try {
|
||||
t.apply(env({ type: "rare:type", senderId: "peer-1" }), ctx);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.THROTTLED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("burst capacity is honoured up to the bucket size", () => {
|
||||
const now = 0;
|
||||
const t = new ThrottlingMiddleware({
|
||||
global: { capacity: 5, refillRate: 1 },
|
||||
clock: () => now,
|
||||
});
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
expect(t.apply(env(), ctx)).not.toBeNull();
|
||||
}
|
||||
try {
|
||||
t.apply(env(), ctx);
|
||||
expect.fail("should have thrown");
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
});
|
||||
|
||||
it("no-op when no limits configured", () => {
|
||||
const t = new ThrottlingMiddleware();
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
expect(t.apply(env(), ctx)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses Date.now as the default clock", () => {
|
||||
// Construct without an injected clock so the default arrow is
|
||||
// wired into every TokenBucket and exercised on apply.
|
||||
const t = new ThrottlingMiddleware({
|
||||
global: { capacity: 5, refillRate: 1 },
|
||||
});
|
||||
expect(t.apply(env(), ctx)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
# `TransformationMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Applies per-`MessageType` transforms to the envelope. Use it for the routine, predictable mutations the chain owns: compressing large payloads, stripping debug fields in production, injecting OTel trace context, redacting fields by policy. Sits in the NORMAL band by default (`priority: 220`) so it runs after security/validation but before metrics and logging.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-`MessageType` transform map, plus optional `defaultTransform` for catch-all behaviour.
|
||||
- Transforms MAY be async; the middleware always awaits.
|
||||
- A transform may return `null` to drop the message (the manager surfaces this via `ChainResult.dropped`), `undefined` to leave the envelope unchanged, or a new envelope.
|
||||
- Direction filter respected — pair with `direction: "outgoing"` for a "compress on send" rule.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransformationMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(
|
||||
new TransformationMiddleware({
|
||||
transforms: {
|
||||
"telemetry:report": (env) => ({
|
||||
...env,
|
||||
payload: redactDebug(env.payload),
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransformationMiddleware,
|
||||
type TransformFn,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const compressIfLarge: TransformFn = async (env) => {
|
||||
if (typeof env.payload !== "string") return env;
|
||||
if (env.payload.length < 4096) return env;
|
||||
return {
|
||||
...env,
|
||||
headers: { ...env.headers, "x-encoding": "gzip" },
|
||||
payload: await gzip(env.payload),
|
||||
};
|
||||
};
|
||||
|
||||
const dropDebug: TransformFn = (env) =>
|
||||
env.type.startsWith("debug:") && process.env.NODE_ENV === "production"
|
||||
? null
|
||||
: undefined;
|
||||
|
||||
const transformer = new TransformationMiddleware({
|
||||
id: "shape-and-drop",
|
||||
priority: 230,
|
||||
direction: "outgoing",
|
||||
transforms: { "chunk:upload": compressIfLarge },
|
||||
defaultTransform: dropDebug,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransformationMiddleware implements Middleware`
|
||||
- `constructor(config?: TransformationMiddlewareConfig)`.
|
||||
- `apply(envelope, context): Promise<MessageEnvelope | null>`.
|
||||
- `interface TransformationMiddlewareConfig` — `{ id?, priority?, direction?, transforms?, defaultTransform? }`.
|
||||
- `type TransformFn` — `(envelope, context) => MessageEnvelope | null | undefined | Promise<…>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Returning `undefined` from a transform is treated as "no change". This keeps trivial pass-throughs short (`(env) => env.x === ok ? undefined : edited(env)`) without forcing callers to spread the envelope on every match.
|
||||
- The middleware always awaits the transform so async transforms (compression, asynchronous redaction, OTel context injection) preserve chain order.
|
||||
- Transforms run for messages whose type matches; messages without an explicit entry fall through unless `defaultTransform` is set. The split lets callers narrow expensive transforms to specific types without hand-coding type checks inside the transform body.
|
||||
- The middleware does NOT validate the result of a transform — that is `ValidationMiddleware`'s job. Compose them in priority order (validate first → transform → re-validate later if needed).
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export type TransformFn = (
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
) =>
|
||||
| MessageEnvelope
|
||||
| null
|
||||
| undefined
|
||||
| Promise<MessageEnvelope | null | undefined>;
|
||||
|
||||
export interface TransformationMiddlewareConfig {
|
||||
readonly id?: string;
|
||||
readonly priority?: number;
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
/** Per-MessageType transforms applied in registration order. */
|
||||
readonly transforms?: Readonly<Record<string, TransformFn>>;
|
||||
/** Transform applied to messages whose type has no specific entry. */
|
||||
readonly defaultTransform?: TransformFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the envelope per `MessageType`. Common uses: compress
|
||||
* large payloads, strip debug fields in production, inject trace
|
||||
* context for distributed tracing. Returning `null` from a transform
|
||||
* propagates as a chain-drop (the manager surfaces it via
|
||||
* `ChainResult.dropped`).
|
||||
*/
|
||||
export class TransformationMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly transforms: Readonly<Record<string, TransformFn>>;
|
||||
private readonly defaultTransform: TransformFn | undefined;
|
||||
|
||||
public constructor(config: TransformationMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "transformer";
|
||||
this.priority = config.priority ?? 220;
|
||||
this.direction = config.direction;
|
||||
this.transforms = config.transforms ?? {};
|
||||
this.defaultTransform = config.defaultTransform;
|
||||
}
|
||||
|
||||
public async apply(
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
): Promise<MessageEnvelope | null> {
|
||||
const fn = this.transforms[envelope.type] ?? this.defaultTransform;
|
||||
if (fn === undefined) return envelope;
|
||||
const next = await fn(envelope, context);
|
||||
if (next === null) return null;
|
||||
return next ?? envelope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createEnvelope, type MessageEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TransformationMiddleware } from "./transformation-middleware.js";
|
||||
|
||||
const env = (type: string, payload: unknown) =>
|
||||
createEnvelope({ type, senderId: "peer-1", payload });
|
||||
|
||||
const ctx = {
|
||||
direction: "outgoing" as const,
|
||||
state: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
describe("TransformationMiddleware: type-specific transforms", () => {
|
||||
it("passes through when no transform is registered", async () => {
|
||||
const t = new TransformationMiddleware();
|
||||
const e = env("any", "x");
|
||||
expect(await t.apply(e, ctx)).toBe(e);
|
||||
});
|
||||
|
||||
it("applies the type-specific transform", async () => {
|
||||
const t = new TransformationMiddleware({
|
||||
transforms: {
|
||||
stringify: (e) => ({
|
||||
...e,
|
||||
payload: String(e.payload).toUpperCase(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await t.apply(env("stringify", "hi"), ctx);
|
||||
expect(result?.payload).toBe("HI");
|
||||
});
|
||||
|
||||
it("returning undefined leaves the envelope unchanged", async () => {
|
||||
const t = new TransformationMiddleware({
|
||||
transforms: {
|
||||
noop: () => undefined,
|
||||
},
|
||||
});
|
||||
const e = env("noop", "x");
|
||||
expect(await t.apply(e, ctx)).toBe(e);
|
||||
});
|
||||
|
||||
it("returning null drops the message", async () => {
|
||||
const t = new TransformationMiddleware({
|
||||
transforms: {
|
||||
drop: () => null,
|
||||
},
|
||||
});
|
||||
expect(await t.apply(env("drop", "x"), ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to defaultTransform when no per-type entry matches", async () => {
|
||||
const t = new TransformationMiddleware({
|
||||
defaultTransform: (e: MessageEnvelope) => ({
|
||||
...e,
|
||||
headers: { ...e.headers, "x-default": "yes" },
|
||||
}),
|
||||
});
|
||||
const result = await t.apply(env("any", "x"), ctx);
|
||||
expect(result?.headers["x-default"]).toBe("yes");
|
||||
});
|
||||
|
||||
it("supports async transforms", async () => {
|
||||
const t = new TransformationMiddleware({
|
||||
transforms: {
|
||||
async: async (e) => ({
|
||||
...e,
|
||||
payload: `async:${String(e.payload)}`,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await t.apply(env("async", "x"), ctx);
|
||||
expect(result?.payload).toBe("async:x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
# Chain types — `@suaveplan/network-plugin-middleware`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shapes the manager uses to report what happened during a chain run and to configure how it reacts when a middleware throws. Both shapes live here (rather than in the manager module) so consumers — bus subscribers, telemetry adapters, custom dispatchers — can import the types without pulling in the manager runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- `ErrorPolicy` closed union (`continue`, `stop`, `retry`) covering the three meaningful strategies; `ErrorPolicyConfig` carries the optional `maxRetries` knob.
|
||||
- `ChainResult` includes `dropped`, the resulting envelope (or `null`), the ordered list of middleware ids that ran, and an optional `droppedBy` id so callers can report which middleware refused the message.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChainResult,
|
||||
ErrorPolicyConfig,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const policy: ErrorPolicyConfig = { policy: "retry", maxRetries: 3 };
|
||||
declare const result: ChainResult;
|
||||
if (result.dropped) {
|
||||
console.warn("message dropped by", result.droppedBy);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChainResult,
|
||||
ErrorPolicy,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const fallback: ErrorPolicy = "continue";
|
||||
|
||||
declare const result: ChainResult;
|
||||
console.table({
|
||||
dropped: result.dropped,
|
||||
droppedBy: result.droppedBy ?? "—",
|
||||
executed: result.executed.join(" → "),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type ErrorPolicy` — `"continue" | "stop" | "retry"`.
|
||||
- `interface ErrorPolicyConfig` — `{ policy, maxRetries? }`.
|
||||
- `interface ChainResult` — `{ dropped, envelope, executed, droppedBy? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `ChainResult.envelope` is typed `MessageEnvelope | null` rather than `MessageEnvelope?` so consumers cannot accidentally dispatch a dropped envelope by treating `undefined` as "no change".
|
||||
- `executed` is `ReadonlyArray<string>` so consumers cannot mutate the audit trail. The manager builds the array per dispatch and freezes the result before returning.
|
||||
- `ErrorPolicyConfig.maxRetries` is only honoured under `policy: "retry"` so the policy / knobs stay grouped — callers that misconfigure another policy with a `maxRetries` value get no surprises.
|
||||
- These types do not include retry attempt counts: the goal of `ChainResult` is *what happened*, not *how it got there*. Adding finer-grained instrumentation belongs in `metrics-middleware` so callers do not pay the cost when they do not opt in.
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
|
||||
/**
|
||||
* Strategy for handling middleware errors. Default is `"stop"`.
|
||||
*
|
||||
* - `continue`: log the error and pass the un-mutated envelope to the
|
||||
* next middleware. Use for non-critical observability middleware.
|
||||
* - `stop`: drop the message. Use for any middleware whose failure
|
||||
* means the message MUST NOT proceed (validation, security).
|
||||
* - `retry`: re-invoke the throwing middleware up to `maxRetries`
|
||||
* times before falling back to `stop`. Use for flaky external
|
||||
* dependencies.
|
||||
*/
|
||||
export type ErrorPolicy = "continue" | "stop" | "retry";
|
||||
|
||||
export interface ErrorPolicyConfig {
|
||||
readonly policy: ErrorPolicy;
|
||||
/** Only honoured when `policy === "retry"`. Defaults to 1. */
|
||||
readonly maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a single middleware-chain run. The manager returns this
|
||||
* so callers can branch on `dropped: true` (skip downstream send) and
|
||||
* inspect which middlewares modified the envelope.
|
||||
*/
|
||||
export interface ChainResult {
|
||||
/** True when a middleware returned `null`. */
|
||||
readonly dropped: boolean;
|
||||
/** The envelope after the chain ran (`null` when dropped). */
|
||||
readonly envelope: MessageEnvelope | null;
|
||||
/** Ids of middlewares that ran (in execution order). */
|
||||
readonly executed: ReadonlyArray<string>;
|
||||
/** Optional id of the middleware that dropped the message. */
|
||||
readonly droppedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# Middleware types — `@suaveplan/network-plugin-middleware`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared structural types for the middleware chain (`NET-41`). The contract a middleware author satisfies is a single function (`apply(envelope, context)`) returning the next envelope, `null` to drop, or a Promise of either; all of the bookkeeping (priority bands, direction filtering, enable/disable) lives in the manager so middleware classes stay narrow.
|
||||
|
||||
## Features
|
||||
|
||||
- `Middleware` — minimal callable interface every concrete middleware satisfies.
|
||||
- `MiddlewareContext` — carries direction + per-dispatch `state` scratch so middlewares can pass derived values downstream without mutating envelope headers.
|
||||
- `ChainDirection` — closed `"incoming" | "outgoing"` union so dual-direction logic stays exhaustively type-checked.
|
||||
- `MiddlewarePriority` — closed band union (`CRITICAL`, `HIGH`, `NORMAL`, `LOW`, `BACKGROUND`) for callers that prefer the enum to a raw number.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
|
||||
const tagger: Middleware = {
|
||||
id: "header-tagger",
|
||||
priority: 250,
|
||||
apply(envelope: MessageEnvelope, _ctx: MiddlewareContext) {
|
||||
return {
|
||||
...envelope,
|
||||
headers: { ...envelope.headers, "x-tagged": "yes" },
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
|
||||
const inboundOnlyDropper: Middleware = {
|
||||
id: "drop-debug",
|
||||
priority: 350,
|
||||
direction: "incoming",
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
async apply(env: MessageEnvelope, ctx: MiddlewareContext) {
|
||||
ctx.state["sawDebug"] = env.type.startsWith("debug:");
|
||||
return ctx.state["sawDebug"] === true ? null : env;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface Middleware` — `{ id, priority, direction?, enabled?, apply(envelope, context) }` — `apply` MAY be sync or async.
|
||||
- `interface MiddlewareContext` — `{ direction, state }`.
|
||||
- `type ChainDirection` — `"incoming" | "outgoing"`.
|
||||
- `type MiddlewarePriority` — `"CRITICAL" | "HIGH" | "NORMAL" | "LOW" | "BACKGROUND"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `priority` is a raw `number` rather than the band enum so callers can position middleware *within* a band (e.g. `120` vs `180` both inside HIGH). The manager exposes `priorityForBand` to convert when callers prefer the enum.
|
||||
- `direction` is optional: middleware that sets neither runs in both directions. Filtering happens once at registration time so the per-dispatch loop stays branchless.
|
||||
- `enabled` defaults to `true`. The manager respects this on every dispatch so middlewares can flip on/off at runtime without re-registering.
|
||||
- `apply` MAY return a Promise; the manager always awaits, even for sync middlewares, so the chain order is preserved across async boundaries. Synchronous middlewares pay one micro-task per call, which is negligible compared to network RTT.
|
||||
- `state` is a plain object rather than a `Map` so JSON-friendly observability adapters can serialise it for tracing.
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
|
||||
/**
|
||||
* Direction the chain is currently processing. Each middleware MAY
|
||||
* elect to act on only one direction.
|
||||
*/
|
||||
export type ChainDirection = "incoming" | "outgoing";
|
||||
|
||||
/**
|
||||
* Five priority bands the manager honours when ordering middleware.
|
||||
* Numeric ranges follow the spec: CRITICAL 0–99, HIGH 100–199,
|
||||
* NORMAL 200–299, LOW 300–399, BACKGROUND 400+.
|
||||
*/
|
||||
export type MiddlewarePriority =
|
||||
| "CRITICAL"
|
||||
| "HIGH"
|
||||
| "NORMAL"
|
||||
| "LOW"
|
||||
| "BACKGROUND";
|
||||
|
||||
/**
|
||||
* Context passed to every middleware. Carries the live envelope plus
|
||||
* direction and an arbitrary `state` bag the chain creates per
|
||||
* dispatch so middlewares can pass scratch data downstream without
|
||||
* mutating the envelope.
|
||||
*/
|
||||
export interface MiddlewareContext {
|
||||
readonly direction: ChainDirection;
|
||||
readonly state: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure middleware contract. The manager calls `apply(envelope,
|
||||
* context)` for every registered middleware that is enabled and
|
||||
* matches the current direction. Returning `null` drops the message
|
||||
* for the rest of the chain.
|
||||
*/
|
||||
export interface Middleware {
|
||||
readonly id: string;
|
||||
readonly priority: number;
|
||||
readonly direction?: ChainDirection | undefined;
|
||||
readonly enabled?: boolean | undefined;
|
||||
apply(
|
||||
envelope: MessageEnvelope,
|
||||
context: MiddlewareContext
|
||||
): MessageEnvelope | null | Promise<MessageEnvelope | null>;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# `ValidationMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Schema-based payload validation for inbound and outbound messages. Per-`MessageType` Zod schemas describe the expected shape; messages whose type has no entry pass through (or hit the optional `defaultSchema`). Two modes — `strict` (default) raises `VALIDATION_FAILED` so the manager's `error-policy: "stop"` rejects the envelope; `lenient` logs a structured warning and forwards the envelope unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-`MessageType` schema map, plus optional `defaultSchema` for catch-all enforcement.
|
||||
- Strict / lenient modes covering both "block bad messages" and "observe-only deployment" stories.
|
||||
- Lenient mode emits structured warnings (`{ messageId, messageType, issues[] }`) to a pluggable logger so deployments wire whatever sink they prefer.
|
||||
- Lives in CRITICAL band by default (`priority: 50`) so validation runs before any transformation, security, or metrics middleware.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import {
|
||||
ValidationMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(
|
||||
new ValidationMiddleware({
|
||||
schemas: {
|
||||
"registry:add": z.object({ peerId: z.string().min(1) }),
|
||||
},
|
||||
})
|
||||
);
|
||||
await manager.dispatch(envelope, "incoming");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import {
|
||||
ValidationMiddleware,
|
||||
type ValidationLogger,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const warnings: unknown[] = [];
|
||||
const logger: ValidationLogger = {
|
||||
warn: (w) => warnings.push(w),
|
||||
};
|
||||
|
||||
const validator = new ValidationMiddleware({
|
||||
id: "lenient-validator",
|
||||
priority: 60,
|
||||
mode: "lenient",
|
||||
logger,
|
||||
schemas: {
|
||||
"registry:add": z.object({ peerId: z.string().uuid() }),
|
||||
},
|
||||
defaultSchema: z
|
||||
.object({})
|
||||
.passthrough()
|
||||
.refine((p) => Object.keys(p).length > 0, "non-empty payload required"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ValidationMiddleware implements Middleware`
|
||||
- `constructor(config?: ValidationMiddlewareConfig)`.
|
||||
- `apply(envelope, context): MessageEnvelope` — throws `VALIDATION_FAILED` (strict) or warns (lenient).
|
||||
- `interface ValidationMiddlewareConfig` — `{ id?, priority?, direction?, mode?, logger?, schemas?, defaultSchema? }`.
|
||||
- `type ValidationMode` — `"strict" | "lenient"`.
|
||||
- `interface ValidationWarning` — `{ messageId, messageType, issues }`.
|
||||
- `interface ValidationLogger` — `{ warn(warning): void }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Validation uses `safeParse` so a thrown Zod error never escapes the middleware. Strict mode wraps the issues into `NetworkMwError(VALIDATION_FAILED)` so the manager's error policy controls what happens next; lenient mode forwards unchanged so callers see _all_ validation issues over time, not just the first.
|
||||
- Messages without a schema pass through silently. The `defaultSchema` knob lets callers reverse this default for "deny by default" deployments without registering a schema per message type.
|
||||
- The middleware does NOT modify the envelope. Schemas that _transform_ payloads should compose with `TransformationMiddleware` downstream — splitting concerns keeps the validator's behaviour predictable for audit reviews.
|
||||
- The default priority (`50`) places the validator inside the CRITICAL band so it runs before any other middleware. Override via the `priority` knob when validation is informational rather than gating.
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import type { ZodType } from "zod";
|
||||
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export type ValidationMode = "strict" | "lenient";
|
||||
|
||||
export interface ValidationWarning {
|
||||
readonly messageId: string;
|
||||
readonly messageType: string;
|
||||
readonly issues: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface ValidationLogger {
|
||||
warn(warning: ValidationWarning): void;
|
||||
}
|
||||
|
||||
export interface ValidationMiddlewareConfig {
|
||||
readonly id?: string;
|
||||
readonly priority?: number;
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
readonly mode?: ValidationMode;
|
||||
/** Optional logger for lenient warnings. */
|
||||
readonly logger?: ValidationLogger;
|
||||
/** Per-MessageType Zod schemas that the payload must satisfy. */
|
||||
readonly schemas?: Readonly<Record<string, ZodType>>;
|
||||
/** Schema applied to messages whose type has no entry in `schemas`. */
|
||||
readonly defaultSchema?: ZodType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `envelope.payload` against a per-`MessageType` Zod schema.
|
||||
*
|
||||
* - `strict` (default): throw `VALIDATION_FAILED` on the first invalid
|
||||
* payload. Combined with the manager's `error-policy: "stop"`, the
|
||||
* chain rejects the envelope.
|
||||
* - `lenient`: log a warning via the supplied `logger` and forward
|
||||
* the un-mutated envelope so observability sees the issue without
|
||||
* blocking traffic.
|
||||
*/
|
||||
export class ValidationMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly mode: ValidationMode;
|
||||
private readonly logger: ValidationLogger | undefined;
|
||||
private readonly schemas: Readonly<Record<string, ZodType>>;
|
||||
private readonly defaultSchema: ZodType | undefined;
|
||||
|
||||
public constructor(config: ValidationMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "validator";
|
||||
this.priority = config.priority ?? 50;
|
||||
this.direction = config.direction;
|
||||
this.mode = config.mode ?? "strict";
|
||||
this.logger = config.logger;
|
||||
this.schemas = config.schemas ?? {};
|
||||
this.defaultSchema = config.defaultSchema;
|
||||
}
|
||||
|
||||
public apply(
|
||||
envelope: MessageEnvelope,
|
||||
_context: MiddlewareContext
|
||||
): MessageEnvelope {
|
||||
const schema = this.schemas[envelope.type] ?? this.defaultSchema;
|
||||
if (schema === undefined) return envelope;
|
||||
const result = schema.safeParse(envelope.payload);
|
||||
if (result.success) return envelope;
|
||||
const issues = result.error.issues.map((issue) => issue.message);
|
||||
if (this.mode === "lenient") {
|
||||
this.logger?.warn({
|
||||
messageId: envelope.id,
|
||||
messageType: envelope.type,
|
||||
issues,
|
||||
});
|
||||
return envelope;
|
||||
}
|
||||
throw new NetworkMwError(
|
||||
NETWORK_MW_CODES.VALIDATION_FAILED,
|
||||
"validation.payload-rejected",
|
||||
{
|
||||
context: {
|
||||
messageId: envelope.id,
|
||||
messageType: envelope.type,
|
||||
issues,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
|
||||
import {
|
||||
type ValidationLogger,
|
||||
ValidationMiddleware,
|
||||
} from "./validation-middleware.js";
|
||||
|
||||
const env = (type: string, payload: unknown) =>
|
||||
createEnvelope({ type, senderId: "peer-1", payload });
|
||||
|
||||
const ctx = {
|
||||
direction: "incoming" as const,
|
||||
state: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
describe("ValidationMiddleware: schema validation, strict / lenient", () => {
|
||||
it("passes through when no schema is registered for the type", () => {
|
||||
const v = new ValidationMiddleware({});
|
||||
const e = env("unknown:type", { any: "thing" });
|
||||
expect(v.apply(e, ctx)).toBe(e);
|
||||
});
|
||||
|
||||
it("strict mode throws VALIDATION_FAILED on bad payload", () => {
|
||||
const v = new ValidationMiddleware({
|
||||
schemas: {
|
||||
"registry:add": z.object({ peerId: z.string().min(1) }),
|
||||
},
|
||||
});
|
||||
try {
|
||||
v.apply(env("registry:add", { peerId: "" }), ctx);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("strict mode passes on a valid payload", () => {
|
||||
const v = new ValidationMiddleware({
|
||||
schemas: {
|
||||
"registry:add": z.object({ peerId: z.string().min(1) }),
|
||||
},
|
||||
});
|
||||
const e = env("registry:add", { peerId: "abc" });
|
||||
expect(v.apply(e, ctx)).toBe(e);
|
||||
});
|
||||
|
||||
it("lenient mode warns and forwards on bad payload", () => {
|
||||
const warnings: unknown[] = [];
|
||||
const logger: ValidationLogger = {
|
||||
warn: (w) => warnings.push(w),
|
||||
};
|
||||
const v = new ValidationMiddleware({
|
||||
mode: "lenient",
|
||||
logger,
|
||||
schemas: {
|
||||
"registry:add": z.object({ peerId: z.string().min(1) }),
|
||||
},
|
||||
});
|
||||
const e = env("registry:add", { peerId: "" });
|
||||
expect(v.apply(e, ctx)).toBe(e);
|
||||
expect(warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
it("defaultSchema applies when no per-type schema matches", () => {
|
||||
const v = new ValidationMiddleware({
|
||||
defaultSchema: z.object({ ok: z.literal(true) }),
|
||||
});
|
||||
try {
|
||||
v.apply(env("any:type", { ok: false }), ctx);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkMwError).code).toBe(
|
||||
NETWORK_MW_CODES.VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("lenient mode does not require a logger (warning is swallowed)", () => {
|
||||
const v = new ValidationMiddleware({
|
||||
mode: "lenient",
|
||||
schemas: {
|
||||
"registry:add": z.object({ peerId: z.string().min(1) }),
|
||||
},
|
||||
});
|
||||
expect(() => v.apply(env("registry:add", {}), ctx)).not.toThrow();
|
||||
});
|
||||
|
||||
it("default-config constructor (no args) passes through any payload", () => {
|
||||
const v = new ValidationMiddleware();
|
||||
const e = env("any:type", { x: 1 });
|
||||
expect(v.apply(e, ctx)).toBe(e);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-plugin-middleware dep-graph", () => {
|
||||
it("loads index.js and exports every public class", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.MiddlewareManager).toBeDefined();
|
||||
expect(mod.priorityForBand).toBeDefined();
|
||||
expect(mod.LoggingMiddleware).toBeDefined();
|
||||
expect(mod.ValidationMiddleware).toBeDefined();
|
||||
expect(mod.TransformationMiddleware).toBeDefined();
|
||||
expect(mod.MetricsMiddleware).toBeDefined();
|
||||
expect(mod.ThrottlingMiddleware).toBeDefined();
|
||||
expect(mod.SecurityMiddleware).toBeDefined();
|
||||
expect(mod.I18nMiddleware).toBeDefined();
|
||||
expect(mod.MiddlewarePlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir:
|
||||
"../../../node_modules/.vite/packages/network-plugin-middleware",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/network",
|
||||
"@suaveplan/rate-limiter",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user