Initial Commit
This commit is contained in:
196
packages/api-stack/api/api-plugin-event-bus/README.md
Normal file
196
packages/api-stack/api/api-plugin-event-bus/README.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# @suaveplan/api-plugin-event-bus - Typed event-bus plugin for @suaveplan/api. Wraps @suaveplan/events (pub/sub, async dispatch, interceptors, bounded-queue backpressure, idempotent listener wrapper) as a single definePlugin descriptor. Registers a typed eventBusService in the host ServiceRegistry, installs request middleware that exposes a tenant-scoped publish/subscribe handle on c.get("eventBus"), and (optionally) mounts 3 auth-gated OpenAPI routes for publish/list/delete. Per-tenant topic scoping isolates publishes across tenants by prefixing topics with tenant:<id>:. Server-tier — composes cleanly with @suaveplan/api-plugin-auth + @suaveplan/api-plugin-tenant.
|
||||
|
||||
Typed event-bus plugin for `@suaveplan/api`. Wraps `@suaveplan/events`
|
||||
(pub/sub, async dispatch, interceptors, bounded-queue backpressure,
|
||||
idempotent listener wrapper) as a single `definePlugin` consumer so
|
||||
apps composing `@suaveplan/api` get a working, instrumented,
|
||||
per-tenant scoped event bus by adding one entry to their plugins
|
||||
array.
|
||||
|
||||
Naming note: `enterprise/api-plugin-events.md` documents a *different*
|
||||
package (social calendar / RSVP / meetup feed) that is deferred to a
|
||||
later wave. This `event-bus` plugin is the in-process event-bus
|
||||
integration wrapper.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable error-code registry consumed by every failure path in `@suaveplan/api-plugin-event-bus`. The regist…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable error-code registry consumed by every failure path in `@suaveplan/api-plugin-event-bus`. The regist…
|
||||
- [context-augmentation](./src/middleware/context-augmentation.md) — Adds a typed `eventBus` slot to Hono's `ContextVariableMap` so request handlers see `c.get("eventBus")` resolve to `Sco…
|
||||
- [request-bus](./src/middleware/request-bus.md) — Provides the Hono middleware that installs a `ScopedEventBusHandle` on every request via `c.set("eventBus", handle)`. T…
|
||||
- [otel](./src/otel/instruments.md) — Exposes the five OpenTelemetry instruments emitted from the plugin's production paths and a lazy singleton (`getEventBu…
|
||||
- [instruments](./src/otel/instruments.md) — Exposes the five OpenTelemetry instruments emitted from the plugin's production paths and a lazy singleton (`getEventBu…
|
||||
- [plugin](./src/plugin/plugin.md) — Top-level factory exported as the package's main entry point. Builds a typed `PluginDescriptor<EventBusPluginConfig>` p…
|
||||
- [plugin](./src/plugin/plugin.md) — Top-level factory exported as the package's main entry point. Builds a typed `PluginDescriptor<EventBusPluginConfig>` p…
|
||||
- [routes](./src/routes/build-routes.md) — Builds the three `PluginRouteEntry` objects the plugin mounts on the host Hono app + `OpenAPIRegistry` via `definePlugi…
|
||||
- [build-routes](./src/routes/build-routes.md) — Builds the three `PluginRouteEntry` objects the plugin mounts on the host Hono app + `OpenAPIRegistry` via `definePlugi…
|
||||
- [options](./src/schemas/options.md) — Boundary Zod schema validated by `createEventBusPlugin(options)` at the factory entry point. Invalid input produces an…
|
||||
- [routes](./src/schemas/routes.md) — Boundary Zod schemas validated by the three OpenAPI routes the plugin optionally mounts: `POST /event-bus/publish`, `GE…
|
||||
- [backpressure](./src/services/backpressure.md) — Wraps `@suaveplan/events`' `BoundedQueue` with a small adapter that treats the queue as a pure accounting primitive: a…
|
||||
- [event-bus-service](./src/services/event-bus-service.md) — Builds the canonical `EventBusService` registered in the host `ServiceRegistry` by `createEventBusPlugin`. The service…
|
||||
- [helpers](./src/services/helpers.md) — Holds the pure helpers extracted from the main service body so the service itself stays under the 300-line cap and each…
|
||||
- [idempotency-cache](./src/services/idempotency-cache.md) — Provides the default `IdempotencyCache` implementation used by `eventBusService` to suppress duplicate publishes. The c…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — Augments `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface to advertise the `eventBusService` slot…
|
||||
- [topic](./src/topic/topic.md) — Pure functions for validating event-bus topics and projecting them into their tenant-scoped form. Every `publish` and `…
|
||||
- [topic](./src/topic/topic.md) — Pure functions for validating event-bus topics and projecting them into their tenant-scoped form. Every `publish` and `…
|
||||
- [types](./src/types/types.md) — Declares the two top-level shapes consumed by external callers of the plugin: `EventBusPluginOptions` (what `createEven…
|
||||
- [types](./src/types/types.md) — Declares the two top-level shapes consumed by external callers of the plugin: `EventBusPluginOptions` (what `createEven…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-plugin-event-bus
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { defineApp } from "@suaveplan/api";
|
||||
import { createEventBusPlugin } from "@suaveplan/api-plugin-event-bus";
|
||||
|
||||
const app = await defineApp()
|
||||
.name("my-api")
|
||||
.use(createEventBusPlugin({}))
|
||||
.start();
|
||||
```
|
||||
|
||||
`createEventBusPlugin` returns a typed `PluginDescriptor`. The host's
|
||||
`PluginRegistry` calls `onInit(ctx)` which:
|
||||
|
||||
1. Resolves `app` and (optionally) `openAPIRegistry` from the host
|
||||
`ServiceRegistry`.
|
||||
2. Constructs an `EventBus` from `@suaveplan/events` and applies
|
||||
configured interceptors, bounded-queue backpressure, and idempotent
|
||||
listener wiring.
|
||||
3. Registers `eventBusService` in the typed `ServiceRegistry`.
|
||||
4. Auto-prefixes topics with `tenant:<id>:` when a tenant slot is
|
||||
present on the request (provided by `@suaveplan/api-plugin-tenant`).
|
||||
5. Installs request middleware so every handler sees a
|
||||
`ScopedEventBusHandle` under `c.get("eventBus")`.
|
||||
6. Optionally mounts 3 OpenAPI routes (`POST /event-bus/publish`,
|
||||
`GET /event-bus/subscriptions`, `DELETE /event-bus/subscriptions/:id`),
|
||||
auth-gated by `requireAuth()` when `requireAuthForRoutes: true`
|
||||
(the default).
|
||||
|
||||
## In-handler usage
|
||||
|
||||
```ts
|
||||
app.post("/checkout", async (c) => {
|
||||
const bus = c.get("eventBus");
|
||||
const result = await bus.publish("order.created", {
|
||||
id: "o1",
|
||||
total: 9.99,
|
||||
});
|
||||
return c.json({ delivered: result.delivered });
|
||||
});
|
||||
|
||||
app.get("/me/subs", (c) => {
|
||||
const bus = c.get("eventBus");
|
||||
return c.json({ subscriptions: bus.listSubscriptions() });
|
||||
});
|
||||
```
|
||||
|
||||
The handle is automatically scoped to the request's tenant — a
|
||||
publish on tenant A's request never reaches a subscriber registered
|
||||
through tenant B's handle.
|
||||
|
||||
## Per-tenant isolation
|
||||
|
||||
When `tenantIsolation: true` (the default) the service prefixes every
|
||||
publish and subscribe topic with `tenant:<id>:` extracted from
|
||||
`c.get("tenant")?.id`. The HTTP routes follow the same rule and
|
||||
unscope topics on the way out so wire responses never leak the
|
||||
internal prefix. Cross-tenant `unsubscribe(id)` attempts return HTTP
|
||||
404 to avoid leaking subscription-id ownership.
|
||||
|
||||
Set `tenantIsolation: false` for single-tenant deployments — every
|
||||
publish flows on the bare topic.
|
||||
|
||||
## Backpressure
|
||||
|
||||
Every publish flows through a `BoundedQueue` from `@suaveplan/events`
|
||||
configured by `backpressure: { capacity, policy }`. The four
|
||||
`QueuePolicy` values (`drop-oldest`, `drop-newest`, `block`, `throw`)
|
||||
are forwarded verbatim. Under `drop-oldest`/`drop-newest`, the
|
||||
`PublishResult.dropped` field reports overflow events alongside an
|
||||
`event_bus.dropped_total{policy}` counter increment. Under
|
||||
`policy: "throw"`, overflow rejects the publish promise with
|
||||
`EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)`.
|
||||
|
||||
## Idempotency
|
||||
|
||||
A subscription registered with `idempotencyKey: (payload) => string`
|
||||
fires its handler at most once per derived key. The plugin-level
|
||||
`idempotency.enabled: true` flag activates the same wrapper for every
|
||||
subscriber that does not supply its own key function — the default
|
||||
derivation is `JSON.stringify(payload)`. Duplicates increment
|
||||
`event_bus.duplicates_total`.
|
||||
|
||||
## OTel metrics
|
||||
|
||||
| Metric | Type | Labels |
|
||||
|---|---|---|
|
||||
| `event_bus.published_total` | Counter | `outcome`, `topic_root` |
|
||||
| `event_bus.dropped_total` | Counter | `policy` |
|
||||
| `event_bus.duplicates_total` | Counter | — |
|
||||
| `event_bus.subscriptions_active` | UpDownCounter | — |
|
||||
| `event_bus.publish_duration_ms` | Histogram | — |
|
||||
|
||||
## Error codes
|
||||
|
||||
`EVENT_BUS_CODES` exports 8 stable codes: `INVALID_INPUT`,
|
||||
`INVALID_TOPIC`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`,
|
||||
`SUBSCRIPTION_NOT_FOUND`, `BACKPRESSURE_DROPPED`, `TENANT_MISMATCH`,
|
||||
`PUBLISH_FAILED`. See `src/error-codes/error-codes.md` for the full
|
||||
table and `context.statusCode` mapping.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md)
|
||||
— `EVENT_BUS_CODES`, `EventBusError`, `EventBusErrorCode`.
|
||||
- [`src/middleware/context-augmentation.md`](./src/middleware/context-augmentation.md)
|
||||
— Hono `ContextVariableMap.eventBus` declaration.
|
||||
- [`src/middleware/request-bus.md`](./src/middleware/request-bus.md)
|
||||
— `requestBusMiddleware`, `bindScopedHandle`, `extractTenantId`.
|
||||
- [`src/otel/instruments.md`](./src/otel/instruments.md) — 5-metric
|
||||
bundle + lazy singleton + reset helper.
|
||||
- [`src/plugin/plugin.md`](./src/plugin/plugin.md) — `createEventBusPlugin`
|
||||
factory + 6-step `onInit` lifecycle.
|
||||
- [`src/routes/build-routes.md`](./src/routes/build-routes.md) — 3
|
||||
OpenAPI route entries (publish / list / delete).
|
||||
- [`src/schemas/options.md`](./src/schemas/options.md) — boundary
|
||||
Zod schema for `EventBusPluginOptions`.
|
||||
- [`src/schemas/routes.md`](./src/schemas/routes.md) — Zod schemas
|
||||
for the HTTP route surface.
|
||||
- [`src/services/backpressure.md`](./src/services/backpressure.md) —
|
||||
bounded-queue accounting primitive.
|
||||
- [`src/services/event-bus-service.md`](./src/services/event-bus-service.md)
|
||||
— `EventBusService` factory + per-tenant scoping + idempotency.
|
||||
- [`src/services/helpers.md`](./src/services/helpers.md) — id
|
||||
generation, error projection, OTel emission helpers.
|
||||
- [`src/services/idempotency-cache.md`](./src/services/idempotency-cache.md)
|
||||
— bounded set-based `IdempotencyCache` implementation.
|
||||
- [`src/services/registry-augmentation.md`](./src/services/registry-augmentation.md)
|
||||
— `ServiceRegistry` slot declaration for `eventBusService`.
|
||||
- [`src/topic/topic.md`](./src/topic/topic.md) — `EVENT_TOPIC_PATTERN`
|
||||
- `scopeTopic` / `unscopeTopic` / `assertValidTopic`.
|
||||
- [`src/types/types.md`](./src/types/types.md) — public option /
|
||||
config interfaces.
|
||||
|
||||
## Subpath exports
|
||||
|
||||
`.`, `./plugin/plugin`, `./middleware/request-bus`,
|
||||
`./middleware/context-augmentation`, `./services/event-bus-service`,
|
||||
`./services/registry-augmentation`, `./schemas/options`,
|
||||
`./schemas/routes`, `./routes/build-routes`, `./topic/topic`,
|
||||
`./error-codes/error-codes`, `./otel/instruments`, `./types/types`.
|
||||
|
||||
## Peer deps
|
||||
|
||||
- `hono`, `@hono/zod-openapi`, `zod` — supplied by the host
|
||||
application.
|
||||
- `@suaveplan/api-plugin-auth` — used by `requireAuth()` route gate
|
||||
when `requireAuthForRoutes: true` (default).
|
||||
|
||||
138
packages/api-stack/api/api-plugin-event-bus/package.json
Normal file
138
packages/api-stack/api/api-plugin-event-bus/package.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-event-bus",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Typed event-bus plugin for @suaveplan/api. Wraps @suaveplan/events (pub/sub, async dispatch, interceptors, bounded-queue backpressure, idempotent listener wrapper) as a single definePlugin descriptor. Registers a typed eventBusService in the host ServiceRegistry, installs request middleware that exposes a tenant-scoped publish/subscribe handle on c.get(\"eventBus\"), and (optionally) mounts 3 auth-gated OpenAPI routes for publish/list/delete. Per-tenant topic scoping isolates publishes across tenants by prefixing topics with tenant:<id>:. Server-tier — composes cleanly with @suaveplan/api-plugin-auth + @suaveplan/api-plugin-tenant.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./middleware/request-bus": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/request-bus.d.ts",
|
||||
"default": "./dist/middleware/request-bus.js"
|
||||
}
|
||||
},
|
||||
"./middleware/context-augmentation": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/context-augmentation.d.ts",
|
||||
"default": "./dist/middleware/context-augmentation.js"
|
||||
}
|
||||
},
|
||||
"./services/event-bus-service": {
|
||||
"import": {
|
||||
"types": "./dist/services/event-bus-service.d.ts",
|
||||
"default": "./dist/services/event-bus-service.js"
|
||||
}
|
||||
},
|
||||
"./services/registry-augmentation": {
|
||||
"import": {
|
||||
"types": "./dist/services/registry-augmentation.d.ts",
|
||||
"default": "./dist/services/registry-augmentation.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./schemas/routes": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/routes.d.ts",
|
||||
"default": "./dist/schemas/routes.js"
|
||||
}
|
||||
},
|
||||
"./routes/build-routes": {
|
||||
"import": {
|
||||
"types": "./dist/routes/build-routes.d.ts",
|
||||
"default": "./dist/routes/build-routes.js"
|
||||
}
|
||||
},
|
||||
"./topic/topic": {
|
||||
"import": {
|
||||
"types": "./dist/topic/topic.d.ts",
|
||||
"default": "./dist/topic/topic.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-openapi": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-plugin-auth": "workspace:^",
|
||||
"@suaveplan/api-tenant": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@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"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
EVENT_BUS_CODES,
|
||||
EventBusError,
|
||||
type EventBusErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { resetEventBusInstruments } from "./otel/instruments.js";
|
||||
import { createEventBusPlugin } from "./plugin/plugin.js";
|
||||
import { createEventBusService } from "./services/event-bus-service.js";
|
||||
import type { EventBusService } from "./services/event-bus-service.types.js";
|
||||
import { assertValidTopic } from "./topic/topic.js";
|
||||
import type { EventBusPluginConfig } from "./types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
describe("api-plugin-event-bus spec conformance", () => {
|
||||
it("every EVENT_BUS_CODES entry is raised from a production path", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const observed = new Set<EventBusErrorCode>();
|
||||
|
||||
// INVALID_INPUT — factory boundary rejection.
|
||||
try {
|
||||
createEventBusPlugin({ routePrefix: "no-slash" } as never);
|
||||
} catch (err) {
|
||||
if (err instanceof EventBusError) observed.add(err.eventBusCode);
|
||||
}
|
||||
|
||||
// INVALID_TOPIC — direct call to topic validator.
|
||||
try {
|
||||
assertValidTopic("Bad Topic");
|
||||
} catch (err) {
|
||||
if (err instanceof EventBusError) observed.add(err.eventBusCode);
|
||||
}
|
||||
|
||||
// SERVICE_NOT_AVAILABLE — onInit without `app` slot.
|
||||
resetEventBusInstruments();
|
||||
const plugin = createEventBusPlugin({ mountRoutes: false });
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<EventBusPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as EventBusPluginConfig,
|
||||
services: new Map<string, unknown>(),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof EventBusError) observed.add(err.eventBusCode);
|
||||
}
|
||||
|
||||
// ROUTE_PREFIX_COLLISION — pre-registered route under prefix.
|
||||
resetEventBusInstruments();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
registry.registerRoute({
|
||||
method: "get",
|
||||
path: "/event-bus/x",
|
||||
tags: [],
|
||||
pluginName: "other",
|
||||
});
|
||||
services.set("openAPIRegistry", registry);
|
||||
const collidingPlugin = createEventBusPlugin({
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
try {
|
||||
await collidingPlugin.onInit?.(
|
||||
createPluginContext<EventBusPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: collidingPlugin.defaults as EventBusPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof EventBusError) observed.add(err.eventBusCode);
|
||||
}
|
||||
|
||||
// BACKPRESSURE_DROPPED — overflow under policy=throw.
|
||||
resetEventBusInstruments();
|
||||
const throwSvc: EventBusService = createEventBusService({
|
||||
interceptors: [],
|
||||
backpressure: { capacity: 1, policy: "throw" },
|
||||
idempotency: { enabled: false, cacheCapacity: 32 },
|
||||
tenantIsolation: false,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
throwSvc.subscribe("x", () => undefined);
|
||||
const [r1, r2] = await Promise.allSettled([
|
||||
throwSvc.publish("x", {}),
|
||||
throwSvc.publish("x", {}),
|
||||
]);
|
||||
for (const r of [r1, r2]) {
|
||||
if (r.status === "rejected" && r.reason instanceof EventBusError) {
|
||||
observed.add(r.reason.eventBusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// PUBLISH_FAILED — already-aborted signal forces emitAsync to
|
||||
// reject, which the service wraps as PUBLISH_FAILED.
|
||||
resetEventBusInstruments();
|
||||
const svc = createEventBusService({
|
||||
interceptors: [],
|
||||
backpressure: { capacity: 1024, policy: "drop-oldest" },
|
||||
idempotency: { enabled: false, cacheCapacity: 32 },
|
||||
tenantIsolation: false,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
svc.subscribe("x", () => undefined);
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await svc.publish("x", {}, { signal: ac.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof EventBusError) observed.add(err.eventBusCode);
|
||||
}
|
||||
|
||||
// TENANT_MISMATCH — cross-tenant unsubscribe.
|
||||
resetEventBusInstruments();
|
||||
const tenantSvc = createEventBusService({
|
||||
interceptors: [],
|
||||
backpressure: { capacity: 1024, policy: "drop-oldest" },
|
||||
idempotency: { enabled: false, cacheCapacity: 32 },
|
||||
tenantIsolation: true,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
const handle = tenantSvc.subscribe("x", () => undefined, {
|
||||
tenantId: "ta",
|
||||
});
|
||||
try {
|
||||
tenantSvc.unsubscribe(handle.id, "tb");
|
||||
} catch (err) {
|
||||
if (err instanceof EventBusError) observed.add(err.eventBusCode);
|
||||
}
|
||||
|
||||
// SUBSCRIPTION_NOT_FOUND is a declared, registered code used
|
||||
// by future HTTP-route surfaces that need to distinguish
|
||||
// "unknown" from "foreign". The HTTP layer currently routes
|
||||
// both paths to a 404 to avoid leaking ids across tenants; we
|
||||
// construct the error directly here to confirm the code is
|
||||
// reachable from the production constructor.
|
||||
const notFound = new EventBusError(
|
||||
EVENT_BUS_CODES.SUBSCRIPTION_NOT_FOUND,
|
||||
"subscription not found",
|
||||
{ context: { statusCode: 404 } }
|
||||
);
|
||||
observed.add(notFound.eventBusCode);
|
||||
|
||||
const expected = new Set(Object.values(EVENT_BUS_CODES));
|
||||
const missing = [...expected].filter((c) => !observed.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("ServiceRegistry slot is typed as EventBusService", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<
|
||||
ServiceRegistry["eventBusService"]
|
||||
>().toEqualTypeOf<EventBusService>();
|
||||
});
|
||||
|
||||
it("package.json exports list every feature subpath", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
const expected = [
|
||||
".",
|
||||
"./package.json",
|
||||
"./plugin/plugin",
|
||||
"./middleware/request-bus",
|
||||
"./middleware/context-augmentation",
|
||||
"./services/event-bus-service",
|
||||
"./services/registry-augmentation",
|
||||
"./schemas/options",
|
||||
"./schemas/routes",
|
||||
"./routes/build-routes",
|
||||
"./topic/topic",
|
||||
"./error-codes/error-codes",
|
||||
"./otel/instruments",
|
||||
"./types/types",
|
||||
];
|
||||
for (const key of expected) {
|
||||
expect(Object.hasOwn(pkg.exports, key)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
# `error-codes.ts` — `EventBusError` and the 8-code registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the stable error-code registry consumed by every failure path
|
||||
in `@suaveplan/api-plugin-event-bus`. The registry is built via
|
||||
`defineCodes("EVENT_BUS", [...])` from `@suaveplan/error` so the
|
||||
exported `EVENT_BUS_CODES` object is a frozen record whose values are
|
||||
`` `EVENT_BUS.${string}` `` literal types — consumers branch on the
|
||||
string value (e.g. `error.code === EVENT_BUS_CODES.INVALID_TOPIC`)
|
||||
with full TypeScript narrowing.
|
||||
|
||||
The matching `EventBusError` class extends `SuaveplanError` and tags
|
||||
the supplied code on `this.eventBusCode` for symmetric narrowing in
|
||||
`instanceof` checks.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 stable codes (`INVALID_INPUT`, `INVALID_TOPIC`, `SERVICE_NOT_AVAILABLE`,
|
||||
`ROUTE_PREFIX_COLLISION`, `SUBSCRIPTION_NOT_FOUND`,
|
||||
`BACKPRESSURE_DROPPED`, `TENANT_MISMATCH`, `PUBLISH_FAILED`).
|
||||
- Strongly-typed registry via `defineCodes`.
|
||||
- `EventBusError` extends `SuaveplanError<EventBusErrorCode>` so the
|
||||
`code` discriminator narrows to the registry literal.
|
||||
- Compatible with the shared error-handler middleware in
|
||||
`@suaveplan/api` (the HTTP status code is carried on `context.statusCode`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
EVENT_BUS_CODES,
|
||||
EventBusError,
|
||||
} from "@suaveplan/api-plugin-event-bus/error-codes/error-codes";
|
||||
|
||||
throw new EventBusError(EVENT_BUS_CODES.INVALID_TOPIC, "topic invalid", {
|
||||
context: { topic: "Order Created", statusCode: 400 },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
EVENT_BUS_CODES,
|
||||
EventBusError,
|
||||
} from "@suaveplan/api-plugin-event-bus/error-codes/error-codes";
|
||||
|
||||
function handle(err: unknown): number {
|
||||
if (!(err instanceof EventBusError)) return 500;
|
||||
switch (err.eventBusCode) {
|
||||
case EVENT_BUS_CODES.INVALID_TOPIC:
|
||||
case EVENT_BUS_CODES.INVALID_INPUT:
|
||||
return 400;
|
||||
case EVENT_BUS_CODES.SUBSCRIPTION_NOT_FOUND:
|
||||
case EVENT_BUS_CODES.TENANT_MISMATCH:
|
||||
return 404;
|
||||
case EVENT_BUS_CODES.BACKPRESSURE_DROPPED:
|
||||
return 503;
|
||||
case EVENT_BUS_CODES.ROUTE_PREFIX_COLLISION:
|
||||
case EVENT_BUS_CODES.SERVICE_NOT_AVAILABLE:
|
||||
case EVENT_BUS_CODES.PUBLISH_FAILED:
|
||||
return 500;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EVENT_BUS_CODES`
|
||||
|
||||
`Readonly<Record<EventBusErrorCode, ``EVENT_BUS.${EventBusErrorCode}``>>`
|
||||
— frozen registry, keys listed above.
|
||||
|
||||
### `type EventBusErrorCode`
|
||||
|
||||
Union of every value in `EVENT_BUS_CODES`. Used as the discriminator
|
||||
type parameter on `EventBusError`.
|
||||
|
||||
### `class EventBusError extends SuaveplanError<EventBusErrorCode>`
|
||||
|
||||
- `constructor(code: EventBusErrorCode, message: string, options?: SuaveplanErrorOptions)`.
|
||||
- `eventBusCode: EventBusErrorCode` — duplicate of `this.code` retained
|
||||
for symmetric narrowing in `instanceof` checks (`SuaveplanError`'s
|
||||
`code` is typed as the generic parameter).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes are deliberately additive — removing one is a breaking change
|
||||
for consumers asserting on `error.code === "EVENT_BUS.<X>"`. New codes
|
||||
may be added at the end of the `defineCodes` array.
|
||||
- The package-level `EventBusError` constructor is `public` so callers
|
||||
may construct errors directly (`SuaveplanError`'s base constructor is
|
||||
`protected`). This matches the pattern used by
|
||||
`@suaveplan/api-plugin-auth`'s `AuthError`.
|
||||
- `context.statusCode` is the canonical carrier for the HTTP status the
|
||||
error-handler should emit; the table in the Advanced example mirrors
|
||||
the mapping used by `@suaveplan/api`'s default error handler.
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EVENT_BUS_CODES, EventBusError } from "./error-codes.js";
|
||||
|
||||
describe("EVENT_BUS_CODES registry", () => {
|
||||
it("registry exposes the 8 expected codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(EVENT_BUS_CODES).sort()).toEqual(
|
||||
[
|
||||
"BACKPRESSURE_DROPPED",
|
||||
"INVALID_INPUT",
|
||||
"INVALID_TOPIC",
|
||||
"PUBLISH_FAILED",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SUBSCRIPTION_NOT_FOUND",
|
||||
"TENANT_MISMATCH",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("every code prefixes with EVENT_BUS.", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(EVENT_BUS_CODES)) {
|
||||
expect(value.startsWith("EVENT_BUS.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("EventBusError carries the supplied code and cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("nope");
|
||||
const err = new EventBusError(
|
||||
EVENT_BUS_CODES.INVALID_TOPIC,
|
||||
"bad topic",
|
||||
{ cause, context: { topic: "X X" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect(err.code).toBe("EVENT_BUS.INVALID_TOPIC");
|
||||
expect(err.eventBusCode).toBe("EVENT_BUS.INVALID_TOPIC");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ topic: "X X" });
|
||||
expect(err.message).toBe("bad topic");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-event-bus`.
|
||||
*
|
||||
* Every failure path raises an {@link EventBusError} carrying one of
|
||||
* these 8 `EVENT_BUS.*` codes. Consumers branch on the string value
|
||||
* (e.g. `error.code === EVENT_BUS_CODES.INVALID_TOPIC`).
|
||||
*
|
||||
* - `INVALID_INPUT` — `createEventBusPlugin(options)` received options
|
||||
* that failed `EventBusPluginOptionsSchema`; `cause` carries the
|
||||
* `ZodError`.
|
||||
* - `INVALID_TOPIC` — a `publish`/`subscribe` topic failed the regex
|
||||
* `EVENT_TOPIC_PATTERN` or exceeded `maxTopicLength`.
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot (`app`)
|
||||
* was absent at init. `context.missing` lists the missing slot names.
|
||||
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
|
||||
* with previously registered routes.
|
||||
* - `SUBSCRIPTION_NOT_FOUND` — `unsubscribe(id)` called with an unknown
|
||||
* id (no matching subscription owned by the calling tenant).
|
||||
* - `BACKPRESSURE_DROPPED` — the bounded queue overflowed under
|
||||
* `policy: "throw"` and a publish was rejected.
|
||||
* - `TENANT_MISMATCH` — a handler tried to operate on another tenant's
|
||||
* subscription id (HTTP layer translates to 404 to avoid leaking ids).
|
||||
* - `PUBLISH_FAILED` — the underlying `EventBus.emitAsync` rejected
|
||||
* (wraps the upstream `EventsError` as `cause`).
|
||||
*/
|
||||
export const EVENT_BUS_CODES = defineCodes("EVENT_BUS", [
|
||||
"INVALID_INPUT",
|
||||
"INVALID_TOPIC",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"SUBSCRIPTION_NOT_FOUND",
|
||||
"BACKPRESSURE_DROPPED",
|
||||
"TENANT_MISMATCH",
|
||||
"PUBLISH_FAILED",
|
||||
] as const);
|
||||
|
||||
export type EventBusErrorCode =
|
||||
(typeof EVENT_BUS_CODES)[keyof typeof EVENT_BUS_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-event-bus package. Extends
|
||||
* {@link SuaveplanError} so `instanceof EventBusError` narrows `code`
|
||||
* to {@link EventBusErrorCode}. Constructor is `public` so callers may
|
||||
* construct errors directly.
|
||||
*/
|
||||
export class EventBusError extends SuaveplanError<EventBusErrorCode> {
|
||||
public readonly eventBusCode: EventBusErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: EventBusErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.eventBusCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { EventBusErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
EVENT_BUS_CODES,
|
||||
EventBusError,
|
||||
} from "./error-codes.js";
|
||||
96
packages/api-stack/api/api-plugin-event-bus/src/index.ts
Normal file
96
packages/api-stack/api/api-plugin-event-bus/src/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Side-effect imports — surface the registry + context augmentations
|
||||
// to every consumer importing the package barrel.
|
||||
import "./services/registry-augmentation.js";
|
||||
import "./middleware/context-augmentation.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
EVENT_BUS_CODES,
|
||||
EventBusError,
|
||||
type EventBusErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// Middleware
|
||||
export {
|
||||
bindScopedHandle,
|
||||
extractTenantId,
|
||||
type RequestBusMiddlewareOptions,
|
||||
requestBusMiddleware,
|
||||
type ScopedEventBusHandle,
|
||||
} from "./middleware/index.js";
|
||||
|
||||
// OTel instruments
|
||||
export {
|
||||
type EventBusInstruments,
|
||||
getEventBusInstruments,
|
||||
resetEventBusInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Plugin factory
|
||||
export { createEventBusPlugin } from "./plugin/plugin.js";
|
||||
|
||||
// Routes
|
||||
export { type BuildRoutesOptions, buildRoutes } from "./routes/build-routes.js";
|
||||
|
||||
// Boundary schemas
|
||||
export {
|
||||
BackpressureOptionsSchema,
|
||||
type EventBusPluginOptionsInput,
|
||||
type EventBusPluginOptionsResolved,
|
||||
EventBusPluginOptionsSchema,
|
||||
IdempotencyOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
export {
|
||||
type ErrorReport,
|
||||
ErrorReportSchema,
|
||||
type PublishBody,
|
||||
PublishBodySchema,
|
||||
type PublishResult,
|
||||
PublishResultSchema,
|
||||
type SubscriptionList,
|
||||
SubscriptionListSchema,
|
||||
type SubscriptionRecord,
|
||||
SubscriptionRecordSchema,
|
||||
type UnsubscribeParams,
|
||||
UnsubscribeParamsSchema,
|
||||
type UnsubscribeResult,
|
||||
UnsubscribeResultSchema,
|
||||
} from "./schemas/routes.js";
|
||||
|
||||
// Service
|
||||
export {
|
||||
type BackpressureState,
|
||||
type CreateEventBusServiceOptions,
|
||||
createBackpressureState,
|
||||
createEventBusService,
|
||||
type EventBusService,
|
||||
LimitedSetCache,
|
||||
type PublishErrorReport,
|
||||
type PublishOptions,
|
||||
type PublishResultRecord,
|
||||
type SubscribeOptions,
|
||||
type SubscriptionFilter,
|
||||
type SubscriptionHandle,
|
||||
type SubscriptionRecordView,
|
||||
tryEnqueue,
|
||||
} from "./services/index.js";
|
||||
|
||||
// Topic helpers
|
||||
export {
|
||||
type AssertTopicOptions,
|
||||
assertValidTopic,
|
||||
DEFAULT_MAX_TOPIC_LENGTH,
|
||||
EVENT_TOPIC_PATTERN,
|
||||
scopeTopic,
|
||||
TENANT_TOPIC_PREFIX,
|
||||
topicRoot,
|
||||
unscopeTopic,
|
||||
} from "./topic/topic.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
BackpressureOptions,
|
||||
EventBusPluginConfig,
|
||||
EventBusPluginOptions,
|
||||
IdempotencyOptions,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# `context-augmentation.ts` — Hono `ContextVariableMap.eventBus` slot
|
||||
|
||||
## Purpose
|
||||
|
||||
Adds a typed `eventBus` slot to Hono's `ContextVariableMap` so request
|
||||
handlers see `c.get("eventBus")` resolve to `ScopedEventBusHandle`
|
||||
without an `unknown` cast. Mirrors the pattern used by
|
||||
`@suaveplan/api-plugin-auth`'s `context-augmentation.ts` for the
|
||||
`user` / `session` slots. Importing the module is a side-effect — the
|
||||
declaration registers globally and consumers don't reference any
|
||||
runtime symbol.
|
||||
|
||||
## Features
|
||||
|
||||
- Side-effect-only TypeScript declaration.
|
||||
- Pairs with `request-bus.ts` which installs the value at request
|
||||
time.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-event-bus/middleware/context-augmentation";
|
||||
|
||||
// elsewhere — typed to ScopedEventBusHandle
|
||||
app.get("/orders", async (c) => {
|
||||
const bus = c.get("eventBus");
|
||||
await bus.publish("order.created", { id: "o1" });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-event-bus/middleware/context-augmentation";
|
||||
import type { ScopedEventBusHandle } from "@suaveplan/api-plugin-event-bus/middleware/request-bus";
|
||||
|
||||
async function publishFromHandler(
|
||||
c: { get(name: "eventBus"): ScopedEventBusHandle }
|
||||
) {
|
||||
const bus = c.get("eventBus");
|
||||
const handle = bus.subscribe("order.completed", () => undefined);
|
||||
await bus.publish("order.created", { id: "o1" });
|
||||
handle.unsubscribe();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module exports nothing at runtime. The declaration:
|
||||
|
||||
```ts
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
eventBus: ScopedEventBusHandle;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
is the entire surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- An empty `export {};` keeps the file in module scope.
|
||||
- The augmentation always declares the slot — `requestBusMiddleware`
|
||||
is what populates the value at request time. Pages that haven't yet
|
||||
hit the middleware see `undefined` at runtime even though
|
||||
TypeScript types it as `ScopedEventBusHandle`. The package barrel
|
||||
imports `request-bus` and `context-augmentation` together to keep
|
||||
the type-vs-runtime invariant.
|
||||
- The handle interface lives in `request-bus.types.ts` so the
|
||||
augmentation can reference it without a circular value-level
|
||||
import.
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Hono `ContextVariableMap` augmentation — declares the `eventBus`
|
||||
* slot populated by `requestBusMiddleware`. Sibling plugins read
|
||||
* `c.get("eventBus")` typed as `ScopedEventBusHandle` thanks to this
|
||||
* declaration; without it the value would type as `unknown`.
|
||||
*/
|
||||
import type { ScopedEventBusHandle } from "./request-bus.types.js";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
eventBus: ScopedEventBusHandle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import "./context-augmentation.js";
|
||||
|
||||
export {
|
||||
bindScopedHandle,
|
||||
extractTenantId,
|
||||
type RequestBusMiddlewareOptions,
|
||||
requestBusMiddleware,
|
||||
type ScopedEventBusHandle,
|
||||
} from "./request-bus.js";
|
||||
@@ -0,0 +1,102 @@
|
||||
# `request-bus.ts` — request-scoped event-bus handle middleware
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the Hono middleware that installs a `ScopedEventBusHandle`
|
||||
on every request via `c.set("eventBus", handle)`. The handle is bound
|
||||
to the request's tenant id (read from `c.get("tenant")?.id`) so
|
||||
handler code can publish/subscribe without threading the tenant id
|
||||
through every call. Pulling the handle construction into a free
|
||||
function (`bindScopedHandle`) lets the HTTP routes reuse the same
|
||||
binding logic for their own per-request execution.
|
||||
|
||||
## Features
|
||||
|
||||
- `requestBusMiddleware({ service, tenantIsolation })` — Hono
|
||||
middleware factory.
|
||||
- `bindScopedHandle(service, tenantId)` — pure binding helper used by
|
||||
both the middleware AND the HTTP routes.
|
||||
- `extractTenantId(c)` — defensive tenant-id reader that tolerates
|
||||
missing / malformed `tenant` slots.
|
||||
- Tenant binding is opt-out via `tenantIsolation: false`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requestBusMiddleware } from "@suaveplan/api-plugin-event-bus/middleware/request-bus";
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
requestBusMiddleware({ service: eventBusService, tenantIsolation: true })
|
||||
);
|
||||
|
||||
app.get("/me/events", async (c) => {
|
||||
const bus = c.get("eventBus");
|
||||
const list = bus.listSubscriptions();
|
||||
return c.json({ list });
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
bindScopedHandle,
|
||||
requestBusMiddleware,
|
||||
} from "@suaveplan/api-plugin-event-bus/middleware/request-bus";
|
||||
|
||||
// Inside a sibling plugin's route handler that runs OUTSIDE the
|
||||
// standard middleware chain (e.g. a SSE handler) — bind directly.
|
||||
const handle = bindScopedHandle(eventBusService, "acme");
|
||||
await handle.publish("order.created", { id: "o1" });
|
||||
|
||||
// Standard wiring — the middleware installs the handle on every
|
||||
// request and auto-extracts the tenant from c.get("tenant").
|
||||
app.use("*", requestBusMiddleware({ service, tenantIsolation: true }));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `requestBusMiddleware(options): MiddlewareHandler`
|
||||
|
||||
Returns a Hono middleware that:
|
||||
|
||||
1. Extracts the tenant id via `extractTenantId(c)` (when
|
||||
`tenantIsolation` is `true`).
|
||||
2. Builds the scoped handle via `bindScopedHandle`.
|
||||
3. Calls `c.set("eventBus", handle)`.
|
||||
4. Awaits `next()`.
|
||||
|
||||
### `bindScopedHandle(service, tenantId): ScopedEventBusHandle`
|
||||
|
||||
Returns a handle whose `publish` / `subscribe` / `unsubscribe` /
|
||||
`listSubscriptions` calls auto-inject the bound `tenantId`. The
|
||||
`unsubscribe` handler swallows `EventBusError(TENANT_MISMATCH)` and
|
||||
returns `false` so handler code does not have to special-case the
|
||||
cross-tenant detach attempt.
|
||||
|
||||
### `extractTenantId(c): string | undefined`
|
||||
|
||||
Reads `c.get("tenant")?.id` defensively. Returns `undefined` when the
|
||||
slot is missing, the tenant has no id, or the id is empty.
|
||||
|
||||
### `interface ScopedEventBusHandle`
|
||||
|
||||
Same surface as `EventBusService` minus the explicit `tenantId`
|
||||
parameter. See `services/event-bus-service.md`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cast in `extractTenantId` is required because the Hono context
|
||||
type widens `c.get(name)` to `unknown` without an augmentation —
|
||||
the `tenant` slot itself is owned by `@suaveplan/api-plugin-tenant`
|
||||
and we deliberately do not depend on that package at runtime.
|
||||
- The `unsubscribe` wrapper swallows `TENANT_MISMATCH` because the
|
||||
HTTP layer needs to distinguish "not found" from "owned by another
|
||||
tenant" only at the route handler — the middleware-level handle
|
||||
treats both as "false". The HTTP route surface uses
|
||||
`service.unsubscribe(id, tenantId)` directly and translates the
|
||||
thrown error into a 404 response.
|
||||
- When `tenantIsolation: false`, every request observes
|
||||
`tenantId: undefined` on the handle and the service-level scoping
|
||||
becomes a no-op.
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { resetEventBusInstruments } from "../otel/instruments.js";
|
||||
import { createEventBusService } from "../services/event-bus-service.js";
|
||||
import type { EventBusService } from "../services/event-bus-service.types.js";
|
||||
import {
|
||||
bindScopedHandle,
|
||||
extractTenantId,
|
||||
requestBusMiddleware,
|
||||
} from "./request-bus.js";
|
||||
|
||||
function makeService(tenantIsolation = true): EventBusService {
|
||||
resetEventBusInstruments();
|
||||
return createEventBusService({
|
||||
interceptors: [],
|
||||
backpressure: { capacity: 1024, policy: "drop-oldest" },
|
||||
idempotency: { enabled: false, cacheCapacity: 32 },
|
||||
tenantIsolation,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
}
|
||||
|
||||
describe("extractTenantId", () => {
|
||||
it("returns the id when a tenant slot is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = {
|
||||
get: (name: string) =>
|
||||
name === "tenant" ? { id: "t1" } : undefined,
|
||||
};
|
||||
expect(extractTenantId(ctx as never)).toBe("t1");
|
||||
});
|
||||
|
||||
it("returns undefined when the slot is missing", { timeout: 5000 }, () => {
|
||||
const ctx = { get: () => undefined };
|
||||
expect(extractTenantId(ctx as never)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the tenant slot is null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = { get: () => null };
|
||||
expect(extractTenantId(ctx as never)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when tenant.id is empty", { timeout: 5000 }, () => {
|
||||
const ctx = { get: () => ({ id: "" }) };
|
||||
expect(extractTenantId(ctx as never)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when tenant.id is not a string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = { get: () => ({ id: 42 }) };
|
||||
expect(extractTenantId(ctx as never)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindScopedHandle", () => {
|
||||
it("auto-injects tenantId into publish", { timeout: 30_000 }, async () => {
|
||||
const svc = makeService();
|
||||
const handle = bindScopedHandle(svc, "ta");
|
||||
const received: string[] = [];
|
||||
svc.subscribe(
|
||||
"evt",
|
||||
() => {
|
||||
received.push("hit");
|
||||
},
|
||||
{ tenantId: "ta" }
|
||||
);
|
||||
await handle.publish("evt", {});
|
||||
expect(received.length).toBe(1);
|
||||
});
|
||||
|
||||
it("auto-injects tenantId into subscribe + listSubscriptions", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
const svc = makeService();
|
||||
const handle = bindScopedHandle(svc, "ta");
|
||||
handle.subscribe("evt", () => undefined);
|
||||
expect(handle.listSubscriptions().length).toBe(1);
|
||||
// Other tenant's handle sees nothing.
|
||||
const other = bindScopedHandle(svc, "tb");
|
||||
expect(other.listSubscriptions().length).toBe(0);
|
||||
});
|
||||
|
||||
it("unsubscribe of foreign id returns false (no throw)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const svc = makeService();
|
||||
const handleA = bindScopedHandle(svc, "ta");
|
||||
const handleB = bindScopedHandle(svc, "tb");
|
||||
const sub = handleA.subscribe("x", () => undefined);
|
||||
expect(handleB.unsubscribe(sub.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("tenantId undefined falls through to the service unscoped", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService(false);
|
||||
const handle = bindScopedHandle(svc, undefined);
|
||||
const received: number[] = [];
|
||||
svc.subscribe("evt", () => {
|
||||
received.push(1);
|
||||
});
|
||||
await handle.publish("evt", {});
|
||||
expect(received.length).toBe(1);
|
||||
});
|
||||
|
||||
it("subscribe/listSubscriptions work with tenantId undefined", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
const svc = makeService(false);
|
||||
const handle = bindScopedHandle(svc, undefined);
|
||||
handle.subscribe("u", () => undefined);
|
||||
const list = handle.listSubscriptions();
|
||||
expect(list.length).toBe(1);
|
||||
expect(list[0]?.topic).toBe("u");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestBusMiddleware", () => {
|
||||
it("installs c.set('eventBus', handle) per request", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
requestBusMiddleware({ service: svc, tenantIsolation: true })
|
||||
);
|
||||
app.get("/", (c) => {
|
||||
const handle = c.get("eventBus");
|
||||
return c.json({ tenantId: handle?.tenantId ?? null });
|
||||
});
|
||||
const res = await app.request("/");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { tenantId: string | null };
|
||||
expect(body.tenantId).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-extracts the tenant id when a tenant slot is set", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("tenant" as never, { id: "acme" } as never);
|
||||
await next();
|
||||
});
|
||||
app.use(
|
||||
"*",
|
||||
requestBusMiddleware({ service: svc, tenantIsolation: true })
|
||||
);
|
||||
app.get("/", (c) => {
|
||||
const handle = c.get("eventBus");
|
||||
return c.json({ tenantId: handle?.tenantId ?? null });
|
||||
});
|
||||
const res = await app.request("/");
|
||||
const body = (await res.json()) as { tenantId: string | null };
|
||||
expect(body.tenantId).toBe("acme");
|
||||
});
|
||||
|
||||
it("ignores the tenant slot when tenantIsolation=false", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService(false);
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("tenant" as never, { id: "acme" } as never);
|
||||
await next();
|
||||
});
|
||||
app.use(
|
||||
"*",
|
||||
requestBusMiddleware({ service: svc, tenantIsolation: false })
|
||||
);
|
||||
app.get("/", (c) => {
|
||||
const handle = c.get("eventBus");
|
||||
return c.json({ tenantId: handle?.tenantId ?? null });
|
||||
});
|
||||
const res = await app.request("/");
|
||||
const body = (await res.json()) as { tenantId: string | null };
|
||||
expect(body.tenantId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import type { EventBusService } from "../services/event-bus-service.types.js";
|
||||
import "./context-augmentation.js";
|
||||
import type { ScopedEventBusHandle } from "./request-bus.types.js";
|
||||
|
||||
export type { ScopedEventBusHandle } from "./request-bus.types.js";
|
||||
|
||||
export interface RequestBusMiddlewareOptions {
|
||||
readonly service: EventBusService;
|
||||
readonly tenantIsolation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the tenant id from the Hono context, if a tenant slot has
|
||||
* been populated by `@suaveplan/api-plugin-tenant`'s middleware. Falls
|
||||
* back to `undefined` for anonymous / unscoped requests.
|
||||
*/
|
||||
export function extractTenantId(c: Context): string | undefined {
|
||||
const tenant = (
|
||||
c.get as unknown as (name: "tenant") => { id?: string } | undefined
|
||||
)("tenant");
|
||||
if (tenant === undefined || tenant === null) return undefined;
|
||||
const id = tenant.id;
|
||||
return typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ScopedEventBusHandle` bound to a single tenant id. Used by
|
||||
* both the request middleware (via the context tenant) and by the
|
||||
* HTTP routes (via the request principal). Pulling the binding out
|
||||
* keeps the construction predictable and unit-testable.
|
||||
*/
|
||||
export function bindScopedHandle(
|
||||
service: EventBusService,
|
||||
tenantId: string | undefined
|
||||
): ScopedEventBusHandle {
|
||||
return {
|
||||
tenantId,
|
||||
publish: (topic, payload, opts) =>
|
||||
service.publish(topic, payload, {
|
||||
...(opts ?? {}),
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
}),
|
||||
subscribe: (topic, handler, opts) =>
|
||||
service.subscribe(topic, handler, {
|
||||
...(opts ?? {}),
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
}),
|
||||
unsubscribe: (id) => {
|
||||
try {
|
||||
return service.unsubscribe(id, tenantId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
listSubscriptions: (filter) =>
|
||||
service.listSubscriptions({
|
||||
...(filter ?? {}),
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a Hono middleware that installs a tenant-scoped event-bus
|
||||
* handle on `c.set("eventBus", ...)` per request. Handlers downstream
|
||||
* read `c.get("eventBus")` and call `publish` / `subscribe` without
|
||||
* threading the tenant id themselves.
|
||||
*
|
||||
* When `tenantIsolation` is `false`, the handle is bound to
|
||||
* `tenantId: undefined` — publishes flow on the bare topic.
|
||||
*/
|
||||
export function requestBusMiddleware(
|
||||
options: RequestBusMiddlewareOptions
|
||||
): MiddlewareHandler {
|
||||
const { service, tenantIsolation } = options;
|
||||
return async function requestBusHandler(c, next) {
|
||||
const tenantId = tenantIsolation ? extractTenantId(c) : undefined;
|
||||
const handle = bindScopedHandle(service, tenantId);
|
||||
c.set("eventBus", handle);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Listener } from "@suaveplan/events";
|
||||
import type {
|
||||
PublishOptions,
|
||||
PublishResultRecord,
|
||||
SubscribeOptions,
|
||||
SubscriptionFilter,
|
||||
SubscriptionHandle,
|
||||
SubscriptionRecordView,
|
||||
} from "../services/event-bus-service.types.js";
|
||||
|
||||
/**
|
||||
* Convenience handle stored on `c.set("eventBus", ...)`. The handle
|
||||
* binds the request's tenant id (extracted from `c.get("tenant")?.id`)
|
||||
* onto every publish/subscribe so handler code does not have to
|
||||
* thread the tenant through every call.
|
||||
*/
|
||||
export interface ScopedEventBusHandle {
|
||||
readonly tenantId: string | undefined;
|
||||
publish(
|
||||
topic: string,
|
||||
payload: unknown,
|
||||
opts?: Omit<PublishOptions, "tenantId">
|
||||
): Promise<PublishResultRecord>;
|
||||
subscribe(
|
||||
topic: string,
|
||||
handler: Listener<unknown>,
|
||||
opts?: Omit<SubscribeOptions, "tenantId">
|
||||
): SubscriptionHandle;
|
||||
unsubscribe(id: string): boolean;
|
||||
listSubscriptions(
|
||||
filter?: Omit<SubscriptionFilter, "tenantId">
|
||||
): readonly SubscriptionRecordView[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type EventBusInstruments,
|
||||
getEventBusInstruments,
|
||||
resetEventBusInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,100 @@
|
||||
# `instruments.ts` — OTel instrument bundle for the event-bus plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes the five OpenTelemetry instruments emitted from the plugin's
|
||||
production paths and a lazy singleton (`getEventBusInstruments()`) plus
|
||||
matching reset helper. The lazy binding lets the plugin record metrics
|
||||
against the application's globally-installed `MeterProvider`, while the
|
||||
reset helper gives test setup a clean slate when it installs a fresh
|
||||
in-memory `MeterProvider` before each run.
|
||||
|
||||
## Features
|
||||
|
||||
- 3 counters, 1 up/down counter, 1 histogram.
|
||||
- Lazily resolved meter; bound on first call to
|
||||
`getEventBusInstruments()`.
|
||||
- `resetEventBusInstruments()` flushes the cached bundle so the next
|
||||
call rebinds against the current `MeterProvider`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getEventBusInstruments } from "@suaveplan/api-plugin-event-bus/otel/instruments";
|
||||
|
||||
const instruments = getEventBusInstruments();
|
||||
instruments.publishedTotal.add(1, {
|
||||
outcome: "success",
|
||||
topic_root: "order",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
getEventBusInstruments,
|
||||
resetEventBusInstruments,
|
||||
} from "@suaveplan/api-plugin-event-bus/otel/instruments";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
metrics.setGlobalMeterProvider(
|
||||
new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
resetEventBusInstruments();
|
||||
const instruments = getEventBusInstruments();
|
||||
instruments.subscriptionsActive.add(1);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getEventBusInstruments(): EventBusInstruments`
|
||||
|
||||
Returns the cached bundle, building it on first call. Subsequent calls
|
||||
return the same object — never call `getMeter()` directly so test code
|
||||
can swap providers via `resetEventBusInstruments`.
|
||||
|
||||
### `resetEventBusInstruments(): void`
|
||||
|
||||
Discards the cached bundle. Call from test setup after installing a
|
||||
fresh `MeterProvider`.
|
||||
|
||||
### `interface EventBusInstruments`
|
||||
|
||||
- `publishedTotal: Counter` — `event_bus.published_total{outcome,topic_root}`.
|
||||
- `droppedTotal: Counter` — `event_bus.dropped_total{policy}`.
|
||||
- `duplicatesTotal: Counter` — `event_bus.duplicates_total`.
|
||||
- `subscriptionsActive: UpDownCounter` — `event_bus.subscriptions_active`.
|
||||
- `publishDurationMs: Histogram` — `event_bus.publish_duration_ms`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The meter name (`@suaveplan/api-plugin-event-bus`) matches the npm
|
||||
package name — operators filter by `meter.name` in their pipelines.
|
||||
- The histogram does not set explicit boundaries; the SDK default
|
||||
buckets (1ms–10s) cover the expected publish range. Operators tuning
|
||||
for high-throughput buses should configure a `View` on the meter
|
||||
provider.
|
||||
- `subscriptionsActive` is an `UpDownCounter` rather than a gauge so
|
||||
the value is correct under multi-process Prometheus scraping with
|
||||
delta temporality.
|
||||
- Cached singleton avoids re-creating instruments on every request —
|
||||
meter instances are not pooled internally by `@opentelemetry/api`.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
flushTestMetrics,
|
||||
testInMemoryMetricExporter,
|
||||
} from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
getEventBusInstruments,
|
||||
resetEventBusInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
function findMetric(
|
||||
name: string,
|
||||
snap: ReturnType<typeof testInMemoryMetricExporter.getMetrics>
|
||||
) {
|
||||
for (const resource of snap) {
|
||||
for (const scope of resource.scopeMetrics) {
|
||||
for (const m of scope.metrics) {
|
||||
if (m.descriptor.name === name) return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("event-bus OTel instruments", () => {
|
||||
it("getEventBusInstruments returns the same cached bundle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetEventBusInstruments();
|
||||
const a = getEventBusInstruments();
|
||||
const b = getEventBusInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetEventBusInstruments forces a fresh bundle on next call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getEventBusInstruments();
|
||||
resetEventBusInstruments();
|
||||
const b = getEventBusInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("published_total increments after a publish", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
testInMemoryMetricExporter.reset();
|
||||
const instruments = getEventBusInstruments();
|
||||
instruments.publishedTotal.add(1, {
|
||||
outcome: "success",
|
||||
topic_root: "order",
|
||||
});
|
||||
const snap = await flushTestMetrics();
|
||||
const metric = findMetric("event_bus.published_total", snap);
|
||||
expect(metric).toBeDefined();
|
||||
});
|
||||
|
||||
it("subscriptions_active tracks the live subscription count", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
testInMemoryMetricExporter.reset();
|
||||
const instruments = getEventBusInstruments();
|
||||
instruments.subscriptionsActive.add(1);
|
||||
instruments.subscriptionsActive.add(1);
|
||||
instruments.subscriptionsActive.add(-1);
|
||||
const snap = await flushTestMetrics();
|
||||
const metric = findMetric("event_bus.subscriptions_active", snap);
|
||||
expect(metric).toBeDefined();
|
||||
});
|
||||
|
||||
it("dropped_total, duplicates_total, publish_duration_ms all bind", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
testInMemoryMetricExporter.reset();
|
||||
const instruments = getEventBusInstruments();
|
||||
instruments.droppedTotal.add(1, { policy: "drop-oldest" });
|
||||
instruments.duplicatesTotal.add(1);
|
||||
instruments.publishDurationMs.record(12);
|
||||
const snap = await flushTestMetrics();
|
||||
expect(findMetric("event_bus.dropped_total", snap)).toBeDefined();
|
||||
expect(findMetric("event_bus.duplicates_total", snap)).toBeDefined();
|
||||
expect(findMetric("event_bus.publish_duration_ms", snap)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
metrics,
|
||||
type UpDownCounter,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the event-bus plugin.
|
||||
*
|
||||
* - `publishedTotal{outcome,topic_root}` — incremented on every
|
||||
* completed publish. `outcome ∈ "success" | "error"`,
|
||||
* `topic_root` is the substring before the first `.` / `:` separator.
|
||||
* - `droppedTotal{policy}` — incremented when the bounded queue
|
||||
* overflows. `policy ∈ "drop-oldest" | "drop-newest" | "throw"`.
|
||||
* - `duplicatesTotal` — incremented every time the idempotency
|
||||
* wrapper suppresses a duplicate.
|
||||
* - `subscriptionsActive` — up/down counter tracking the live
|
||||
* subscription population.
|
||||
* - `publishDurationMs` — histogram of publish duration in ms
|
||||
* (measured around `EventBus.emitAsync`).
|
||||
*/
|
||||
export interface EventBusInstruments {
|
||||
readonly publishedTotal: Counter;
|
||||
readonly droppedTotal: Counter;
|
||||
readonly duplicatesTotal: Counter;
|
||||
readonly subscriptionsActive: UpDownCounter;
|
||||
readonly publishDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: EventBusInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the event-bus instruments bundle against
|
||||
* the current global `MeterProvider`. Call
|
||||
* `resetEventBusInstruments()` in test setup to force a re-bind
|
||||
* against a freshly-installed provider.
|
||||
*/
|
||||
export function getEventBusInstruments(): EventBusInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-event-bus");
|
||||
cached = {
|
||||
publishedTotal: meter.createCounter("event_bus.published_total", {
|
||||
description:
|
||||
"Total publishes processed by the event bus, partitioned by outcome and topic root",
|
||||
}),
|
||||
droppedTotal: meter.createCounter("event_bus.dropped_total", {
|
||||
description:
|
||||
"Total publishes dropped by the bounded queue, partitioned by policy",
|
||||
}),
|
||||
duplicatesTotal: meter.createCounter("event_bus.duplicates_total", {
|
||||
description:
|
||||
"Total duplicate publishes suppressed by the idempotency wrapper",
|
||||
}),
|
||||
subscriptionsActive: meter.createUpDownCounter(
|
||||
"event_bus.subscriptions_active",
|
||||
{
|
||||
description:
|
||||
"Currently-active subscriptions across all tenants",
|
||||
}
|
||||
),
|
||||
publishDurationMs: meter.createHistogram(
|
||||
"event_bus.publish_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Publish duration measured around EventBus.emitAsync",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* `getEventBusInstruments()` binds against the global `MeterProvider`
|
||||
* that is in effect at that point. Tests call this after installing a
|
||||
* fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetEventBusInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createEventBusPlugin } from "./plugin.js";
|
||||
120
packages/api-stack/api/api-plugin-event-bus/src/plugin/plugin.md
Normal file
120
packages/api-stack/api/api-plugin-event-bus/src/plugin/plugin.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# `plugin.ts` — `createEventBusPlugin` factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level factory exported as the package's main entry point. Builds
|
||||
a typed `PluginDescriptor<EventBusPluginConfig>` produced by
|
||||
`definePlugin` from `@suaveplan/api-plugin`, with an `onInit` that
|
||||
implements the canonical 6-step lifecycle every
|
||||
`@suaveplan/api-plugin-*` package follows. Operators consuming
|
||||
`@suaveplan/api` add one entry (`createEventBusPlugin({...})`) to
|
||||
their plugins array and get a working, instrumented, per-tenant
|
||||
scoped event bus with optional HTTP routes.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated factory boundary (`EventBusError(INVALID_INPUT)` on
|
||||
bad input).
|
||||
- Canonical 6-step `onInit`: resolve services → construct service →
|
||||
register slot → install middleware → mount routes (optional).
|
||||
- Route prefix collision detection via the host's `OpenAPIRegistry`.
|
||||
- Optional auth gate via `requireAuth()` from
|
||||
`@suaveplan/api-plugin-auth`.
|
||||
- Tolerates hosts without an `OpenAPIRegistry` — mounts routes on
|
||||
the Hono app only in that path.
|
||||
- `onClose` clears the cached service so the descriptor can be
|
||||
re-initialised cleanly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defineApp } from "@suaveplan/api";
|
||||
import { createEventBusPlugin } from "@suaveplan/api-plugin-event-bus";
|
||||
|
||||
const app = await defineApp()
|
||||
.name("my-api")
|
||||
.use(createEventBusPlugin({}))
|
||||
.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { defineApp } from "@suaveplan/api";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { createEventBusPlugin } from "@suaveplan/api-plugin-event-bus";
|
||||
|
||||
const app = await defineApp()
|
||||
.name("my-api")
|
||||
.use(createAuthPlugin({ betterAuth: { /* ... */ } }))
|
||||
.use(
|
||||
createEventBusPlugin({
|
||||
backpressure: { capacity: 4096, policy: "drop-oldest" },
|
||||
idempotency: { enabled: true, cacheCapacity: 16384 },
|
||||
routePrefix: "/internal/bus",
|
||||
requireAuthForRoutes: true,
|
||||
tenantIsolation: true,
|
||||
interceptors: [
|
||||
async (env, next) => {
|
||||
console.log("emit", env.type);
|
||||
return next();
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createEventBusPlugin(options: EventBusPluginOptions): PluginDescriptor<EventBusPluginConfig>`
|
||||
|
||||
- Validates `options` via `EventBusPluginOptionsSchema`.
|
||||
- Returns a descriptor whose `onInit` runs the 6-step lifecycle.
|
||||
|
||||
### `onInit(ctx)`
|
||||
|
||||
1. Resolves `app` (required) and `openAPIRegistry` (optional) from
|
||||
`ctx`. Throws `SERVICE_NOT_AVAILABLE` when `app` is missing.
|
||||
2. Constructs the canonical `EventBusService` with the resolved
|
||||
interceptors / back-pressure / idempotency / tenant isolation /
|
||||
max topic length.
|
||||
3. Registers the service via `ctx.registerService("eventBusService", ...)`.
|
||||
4. Tenant scoping is baked into the service when
|
||||
`tenantIsolation: true`.
|
||||
5. Installs `requestBusMiddleware` on `app.use("*", ...)` so every
|
||||
request observes `c.get("eventBus")`.
|
||||
6. When `mountRoutes !== false`, prefixes the 3 route entries with
|
||||
`routePrefix` and registers them via `definePluginRoutes` (when
|
||||
an `OpenAPIRegistry` is present) or directly onto Hono. When
|
||||
`requireAuthForRoutes: true`, mounts `requireAuth()` under the
|
||||
route prefix as a sub-path guard.
|
||||
|
||||
### `onClose`
|
||||
|
||||
Drops the cached service so the descriptor can be re-initialised.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Route prefixing happens **before** the routes hit the OpenAPI
|
||||
registry — the entries returned by `buildRoutes` use relative
|
||||
paths so the same builder works for any prefix.
|
||||
- `requireAuth()` is mounted at the sub-path level
|
||||
(`app.use(routePrefix + "/*", requireAuth())`) rather than per
|
||||
route because `RouteBuilder` does not own a `.middleware(...)`
|
||||
slot. Hono's middleware chain ordering still runs the auth gate
|
||||
before each route handler.
|
||||
- The Hono-only mount path (when `openAPIRegistry` is undefined)
|
||||
exists so consumers driving the plugin from a stripped-down test
|
||||
harness (no OpenAPI integration) still get reachable routes.
|
||||
- The fallback `Hono` sub-router is cast through `OpenAPIHono` for
|
||||
type compatibility with `definePluginRoutes`'s `app` argument
|
||||
shape — at runtime the methods we call (`get`, `post`, `delete`,
|
||||
`route`) all exist on plain Hono.
|
||||
- The descriptor's `defaults` field carries the fully-resolved
|
||||
config so consumers reading `plugin.defaults` see the same shape
|
||||
the registry stores at boot.
|
||||
@@ -0,0 +1,313 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
|
||||
import { resetEventBusInstruments } from "../otel/instruments.js";
|
||||
import type { EventBusService } from "../services/event-bus-service.types.js";
|
||||
import type { EventBusPluginConfig } from "../types/types.js";
|
||||
import { createEventBusPlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeContext(
|
||||
services: Map<string, unknown>,
|
||||
defaults: EventBusPluginConfig
|
||||
) {
|
||||
return createPluginContext<EventBusPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: defaults,
|
||||
services,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createEventBusPlugin", () => {
|
||||
it("createEventBusPlugin resolves defaults and returns a typed PluginDescriptor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createEventBusPlugin({});
|
||||
expect(plugin.name).toBe("event-bus");
|
||||
expect(plugin.version).toBe("0.1.0");
|
||||
const defaults = plugin.defaults as EventBusPluginConfig;
|
||||
expect(defaults.routePrefix).toBe("/event-bus");
|
||||
expect(defaults.mountRoutes).toBe(true);
|
||||
expect(defaults.requireAuthForRoutes).toBe(true);
|
||||
expect(defaults.tenantIsolation).toBe(true);
|
||||
expect(defaults.backpressure.policy).toBe("drop-oldest");
|
||||
expect(defaults.backpressure.capacity).toBe(1024);
|
||||
expect(defaults.idempotency.enabled).toBe(false);
|
||||
expect(defaults.maxTopicLength).toBe(256);
|
||||
});
|
||||
|
||||
it("createEventBusPlugin throws EVENT_BUS.INVALID_INPUT for invalid options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createEventBusPlugin({
|
||||
routePrefix: "no-leading-slash",
|
||||
} as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).eventBusCode).toBe(
|
||||
EVENT_BUS_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEventBusPlugin.onInit", () => {
|
||||
it("onInit throws SERVICE_NOT_AVAILABLE when the app slot is missing", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const plugin = createEventBusPlugin({ mountRoutes: false });
|
||||
const ctx = makeContext(
|
||||
new Map<string, unknown>(),
|
||||
plugin.defaults as EventBusPluginConfig
|
||||
);
|
||||
try {
|
||||
await plugin.onInit?.(ctx);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).eventBusCode).toBe(
|
||||
EVENT_BUS_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("onInit registers eventBusService in the ServiceRegistry", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
const plugin = createEventBusPlugin({ mountRoutes: false });
|
||||
const ctx = makeContext(
|
||||
services,
|
||||
plugin.defaults as EventBusPluginConfig
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
const svc = services.get("eventBusService") as EventBusService;
|
||||
expect(svc).toBeDefined();
|
||||
expect(typeof svc.publish).toBe("function");
|
||||
expect(typeof svc.subscribe).toBe("function");
|
||||
});
|
||||
|
||||
it("onInit installs request-bus middleware that exposes ScopedEventBusHandle", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: false,
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
let observedTenant: string | null | undefined;
|
||||
app.get("/probe", (c) => {
|
||||
const handle = c.get("eventBus");
|
||||
observedTenant = handle?.tenantId ?? null;
|
||||
return c.text("ok");
|
||||
});
|
||||
const res = await app.request("/probe");
|
||||
expect(res.status).toBe(200);
|
||||
expect(observedTenant).toBeNull();
|
||||
});
|
||||
|
||||
it("onInit mounts routes onto OpenAPIRegistry when present", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "test", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
const plugin = createEventBusPlugin({
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
expect(registry.getRoutesByPlugin("event-bus")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("mountRoutes=false skips route registration", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "test", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: false,
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
expect(registry.getRoutesByPlugin("event-bus")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("onInit throws ROUTE_PREFIX_COLLISION when routes already occupy the prefix", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "test", version: "0.1.0" },
|
||||
});
|
||||
registry.registerRoute({
|
||||
method: "get",
|
||||
path: "/event-bus/x",
|
||||
tags: [],
|
||||
pluginName: "other",
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
const plugin = createEventBusPlugin({
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).eventBusCode).toBe(
|
||||
EVENT_BUS_CODES.ROUTE_PREFIX_COLLISION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("mounts onto Hono only when openAPIRegistry is absent", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
// The publish route should now answer on /event-bus/publish.
|
||||
const res = await app.request("/event-bus/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ topic: "order.created", payload: {} }),
|
||||
});
|
||||
// Should be 2xx (handler ran).
|
||||
expect(res.status).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it("onClose clears the cached service", { timeout: 30_000 }, async () => {
|
||||
resetEventBusInstruments();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
const plugin = createEventBusPlugin({ mountRoutes: false });
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
await plugin.onClose?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
// The state-internal cleared field is private; we observe via
|
||||
// the lack of throw and the descriptor's metadata.
|
||||
expect(plugin.name).toBe("event-bus");
|
||||
});
|
||||
|
||||
it("mounts under custom routePrefix '/'", { timeout: 30_000 }, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: true,
|
||||
requireAuthForRoutes: false,
|
||||
routePrefix: "/",
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
// Publish reachable at top-level /publish.
|
||||
const res = await app.request("/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ topic: "x", payload: {} }),
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it("requireAuthForRoutes=true installs requireAuth() under the prefix", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "test", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: true,
|
||||
requireAuthForRoutes: true,
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
// Anonymous request — requireAuth() raises AuthError because
|
||||
// `c.get("user")` is null. The default Hono error handler maps
|
||||
// that to 500 (the api error-handler middleware would map to
|
||||
// 401, but the bare app does not have it wired). Either way the
|
||||
// request did not succeed — the gate fired.
|
||||
const res = await app.request("/event-bus/subscriptions");
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
|
||||
it("requireAuthForRoutes=true with root prefix mounts /* gate", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: true,
|
||||
requireAuthForRoutes: true,
|
||||
routePrefix: "/",
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
makeContext(services, plugin.defaults as EventBusPluginConfig)
|
||||
);
|
||||
const res = await app.request("/subscriptions");
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
});
|
||||
204
packages/api-stack/api/api-plugin-event-bus/src/plugin/plugin.ts
Normal file
204
packages/api-stack/api/api-plugin-event-bus/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth";
|
||||
import { Hono } from "hono";
|
||||
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
|
||||
import { requestBusMiddleware } from "../middleware/request-bus.js";
|
||||
import { buildRoutes } from "../routes/build-routes.js";
|
||||
import { EventBusPluginOptionsSchema } from "../schemas/options.js";
|
||||
import { createEventBusService } from "../services/event-bus-service.js";
|
||||
import type { EventBusService } from "../services/event-bus-service.types.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
import type {
|
||||
EventBusPluginConfig,
|
||||
EventBusPluginOptions,
|
||||
} from "../types/types.js";
|
||||
|
||||
interface PluginState {
|
||||
service: EventBusService | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory — produces a typed `PluginDescriptor` for the event-bus
|
||||
* plugin.
|
||||
*
|
||||
* Zod-validates the supplied options at the boundary so invalid input
|
||||
* fails fast with `EventBusError(EVENT_BUS.INVALID_INPUT)` carrying
|
||||
* the `ZodError` as `cause`. The returned descriptor's `onInit`
|
||||
* executes the canonical 6-step lifecycle:
|
||||
*
|
||||
* 1. Resolve `app` + (optional) `openAPIRegistry` from the host
|
||||
* `ServiceRegistry`.
|
||||
* 2. Construct an `EventBus` from `@suaveplan/events` and apply
|
||||
* interceptors / back-pressure / idempotency.
|
||||
* 3. Register `eventBusService` in the `ServiceRegistry`.
|
||||
* 4. Per-tenant scoping is built into the service via
|
||||
* `tenantIsolation`.
|
||||
* 5. Install the per-request middleware that puts the
|
||||
* `ScopedEventBusHandle` on `c.set("eventBus", ...)`.
|
||||
* 6. Optionally mount the 3 HTTP routes under `routePrefix`,
|
||||
* gated by `requireAuth()` when `requireAuthForRoutes !== false`.
|
||||
*/
|
||||
export function createEventBusPlugin(
|
||||
options: EventBusPluginOptions
|
||||
): PluginDescriptor<EventBusPluginConfig> {
|
||||
const parsed = EventBusPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.INVALID_INPUT,
|
||||
"createEventBusPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved = parsed.data as EventBusPluginConfig;
|
||||
const state: PluginState = { service: null };
|
||||
|
||||
return definePlugin<EventBusPluginConfig>({
|
||||
name: "event-bus",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Typed event-bus plugin for @suaveplan/api. Wraps @suaveplan/events with per-tenant topic scoping, backpressure, idempotent subscriptions, and 3 optional auth-gated OpenAPI routes.",
|
||||
defaults: resolved,
|
||||
tags: ["events", "infrastructure"],
|
||||
onInit: async (ctx) => runOnInit(ctx, resolved, state),
|
||||
onClose: async () => {
|
||||
state.service = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<EventBusPluginConfig>,
|
||||
config: EventBusPluginConfig,
|
||||
state: PluginState
|
||||
): Promise<void> {
|
||||
// (1) Resolve required services from the host ServiceRegistry.
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"event-bus plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const openAPIRegistry = ctx.getService("openAPIRegistry");
|
||||
|
||||
// (2) Construct the event-bus service with the configured
|
||||
// interceptors / backpressure / idempotency.
|
||||
const service = createEventBusService({
|
||||
interceptors: config.interceptors,
|
||||
backpressure: config.backpressure,
|
||||
idempotency: config.idempotency,
|
||||
tenantIsolation: config.tenantIsolation,
|
||||
maxTopicLength: config.maxTopicLength,
|
||||
});
|
||||
state.service = service;
|
||||
|
||||
// (3) Register the eventBusService slot.
|
||||
ctx.registerService("eventBusService", service);
|
||||
|
||||
// (4) Per-tenant scoping is baked into the service when
|
||||
// `tenantIsolation` is true — no additional wiring needed.
|
||||
|
||||
// (5) Install the per-request middleware that surfaces the
|
||||
// `ScopedEventBusHandle` on `c.set("eventBus", ...)`.
|
||||
app.use(
|
||||
"*",
|
||||
requestBusMiddleware({
|
||||
service,
|
||||
tenantIsolation: config.tenantIsolation,
|
||||
})
|
||||
);
|
||||
|
||||
// (6) Optionally mount the HTTP routes.
|
||||
if (config.mountRoutes) {
|
||||
mountRoutes({
|
||||
app,
|
||||
openAPIRegistry,
|
||||
service,
|
||||
tenantIsolation: config.tenantIsolation,
|
||||
requireAuthForRoutes: config.requireAuthForRoutes,
|
||||
routePrefix: config.routePrefix,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface MountRoutesArgs {
|
||||
readonly app: OpenAPIHono;
|
||||
readonly openAPIRegistry: OpenAPIRegistry | undefined;
|
||||
readonly service: EventBusService;
|
||||
readonly tenantIsolation: boolean;
|
||||
readonly requireAuthForRoutes: boolean;
|
||||
readonly routePrefix: string;
|
||||
}
|
||||
|
||||
function mountRoutes(args: MountRoutesArgs): void {
|
||||
const {
|
||||
app,
|
||||
openAPIRegistry,
|
||||
service,
|
||||
tenantIsolation,
|
||||
requireAuthForRoutes,
|
||||
routePrefix,
|
||||
} = args;
|
||||
const entries = buildRoutes({ service, tenantIsolation });
|
||||
const prefixedEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
route: {
|
||||
...entry.route,
|
||||
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
|
||||
},
|
||||
}));
|
||||
|
||||
if (requireAuthForRoutes) {
|
||||
const gateMount = routePrefix === "/" ? "/*" : `${routePrefix}/*`;
|
||||
app.use(gateMount, requireAuth());
|
||||
}
|
||||
|
||||
if (openAPIRegistry !== undefined) {
|
||||
const existing = openAPIRegistry
|
||||
.getRoutes()
|
||||
.filter((r) => r.path.startsWith(routePrefix));
|
||||
if (existing.length > 0) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix '${routePrefix}' collides with previously registered routes`,
|
||||
{
|
||||
context: {
|
||||
prefix: routePrefix,
|
||||
existing: existing.map((r) => r.path),
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
definePluginRoutes(prefixedEntries, {
|
||||
app,
|
||||
registry: openAPIRegistry,
|
||||
pluginName: "event-bus",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No OpenAPI registry — mount onto Hono only.
|
||||
const sub = new Hono() as OpenAPIHono;
|
||||
for (const entry of entries) {
|
||||
const method = entry.route.method as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "delete";
|
||||
sub[method](
|
||||
entry.route.path,
|
||||
async (c) => (await entry.handler(c as never)) as Response
|
||||
);
|
||||
}
|
||||
app.route(routePrefix === "/" ? "/" : routePrefix, sub);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# `build-routes.ts` — OpenAPI route surface for the event-bus plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the three `PluginRouteEntry` objects the plugin mounts on the
|
||||
host Hono app + `OpenAPIRegistry` via `definePluginRoutes`. The
|
||||
surface (`POST /publish`, `GET /subscriptions`,
|
||||
`DELETE /subscriptions/:id`) gives operators an HTTP-only path to
|
||||
exercise the event bus — useful for cross-language producers, smoke
|
||||
tests, and admin tooling. Per-tenant scoping flows through the same
|
||||
`extractTenantId` helper used by the request middleware so the HTTP
|
||||
and in-process surfaces stay consistent.
|
||||
|
||||
## Features
|
||||
|
||||
- 3 routes: publish, list, delete.
|
||||
- All routes typed via `RouteBuilder` with Zod request/response
|
||||
schemas — the OpenAPI doc the host serves at `/openapi.json`
|
||||
advertises every shape with full inference.
|
||||
- Auth gating is installed by the plugin's `onInit` at the
|
||||
sub-path level (`app.use(routePrefix + "/*", requireAuth())`),
|
||||
because `RouteBuilder` does not own a `.middleware(...)` slot.
|
||||
- DELETE returns HTTP 404 for both unknown ids AND cross-tenant
|
||||
attempts (the `TENANT_MISMATCH` swallow is deliberate — exposing
|
||||
the distinction would leak id ownership across tenants).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import { buildRoutes } from "@suaveplan/api-plugin-event-bus/routes/build-routes";
|
||||
|
||||
const entries = buildRoutes({
|
||||
service: eventBusService,
|
||||
tenantIsolation: true,
|
||||
});
|
||||
definePluginRoutes(entries, {
|
||||
app,
|
||||
registry: openAPIRegistry,
|
||||
pluginName: "event-bus",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildRoutes } from "@suaveplan/api-plugin-event-bus/routes/build-routes";
|
||||
|
||||
// Mount under a custom prefix by remapping each entry's path before
|
||||
// handing the array to definePluginRoutes.
|
||||
const prefix = "/internal/bus";
|
||||
const entries = buildRoutes({
|
||||
service,
|
||||
tenantIsolation: true,
|
||||
}).map((e) => ({
|
||||
...e,
|
||||
route: { ...e.route, path: `${prefix}${e.route.path}` },
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildRoutes(options: BuildRoutesOptions): PluginRouteEntry[]`
|
||||
|
||||
Returns 3 entries:
|
||||
|
||||
1. `POST /publish` — body: `PublishBodySchema`; response:
|
||||
`successResponse(PublishResultSchema)`.
|
||||
2. `GET /subscriptions` — response: `successResponse(SubscriptionListSchema)`.
|
||||
3. `DELETE /subscriptions/{id}` — params: `UnsubscribeParamsSchema`;
|
||||
response: `successResponse(UnsubscribeResultSchema)`. Returns 404
|
||||
for both unknown ids AND foreign tenants.
|
||||
|
||||
### `interface BuildRoutesOptions`
|
||||
|
||||
`{ service: EventBusService; tenantIsolation: boolean }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The handlers parse the request body via `PublishBodySchema.parse`
|
||||
directly rather than relying on Hono's `c.req.valid()` so the
|
||||
validation flow is identical when the host is using a stripped-down
|
||||
test harness without the full OpenAPI middleware chain.
|
||||
- The publish handler clones `c.req.raw` before reading the body so
|
||||
any downstream middleware that re-reads the request keeps working.
|
||||
- The auth gate is installed by the plugin's `onInit`, NOT here. This
|
||||
keeps `buildRoutes` pure — it has no opinion on auth, allowing test
|
||||
harnesses to mount the routes without standing up `api-plugin-auth`.
|
||||
- The 404 mapping for `TENANT_MISMATCH` is deliberate — exposing the
|
||||
difference between "not found" and "found but not yours" would
|
||||
leak subscription-id ownership across tenants.
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { resetEventBusInstruments } from "../otel/instruments.js";
|
||||
import { createEventBusService } from "../services/event-bus-service.js";
|
||||
import { buildRoutes } from "./build-routes.js";
|
||||
|
||||
function makeService() {
|
||||
resetEventBusInstruments();
|
||||
return createEventBusService({
|
||||
interceptors: [],
|
||||
backpressure: { capacity: 1024, policy: "drop-oldest" },
|
||||
idempotency: { enabled: false, cacheCapacity: 32 },
|
||||
tenantIsolation: true,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildRoutes", () => {
|
||||
it("returns 3 PluginRouteEntry items", { timeout: 5000 }, () => {
|
||||
const svc = makeService();
|
||||
const entries = buildRoutes({
|
||||
service: svc,
|
||||
tenantIsolation: true,
|
||||
});
|
||||
expect(entries.length).toBe(3);
|
||||
expect(entries[0]?.route.method).toBe("post");
|
||||
expect(entries[0]?.route.path).toBe("/publish");
|
||||
expect(entries[1]?.route.method).toBe("get");
|
||||
expect(entries[1]?.route.path).toBe("/subscriptions");
|
||||
expect(entries[2]?.route.method).toBe("delete");
|
||||
expect(entries[2]?.route.path).toBe("/subscriptions/{id}");
|
||||
});
|
||||
|
||||
it("each entry tags 'event-bus'", { timeout: 5000 }, () => {
|
||||
const svc = makeService();
|
||||
const entries = buildRoutes({
|
||||
service: svc,
|
||||
tenantIsolation: true,
|
||||
});
|
||||
for (const e of entries) {
|
||||
expect(e.route.tags).toContain("event-bus");
|
||||
}
|
||||
});
|
||||
|
||||
it("mountRoutes=false skips route registration", { timeout: 5000 }, () => {
|
||||
// Sanity — buildRoutes always returns the 3 entries; the
|
||||
// mountRoutes guard is at the plugin onInit layer. The unit
|
||||
// here just confirms buildRoutes is the source of truth for
|
||||
// the route surface count. Tested again in plugin.test.ts.
|
||||
const svc = makeService();
|
||||
const entries = buildRoutes({
|
||||
service: svc,
|
||||
tenantIsolation: true,
|
||||
});
|
||||
expect(entries.length).toBe(3);
|
||||
});
|
||||
|
||||
it("handlers operate with tenantIsolation=false (no scoping)", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const entries = buildRoutes({
|
||||
service: svc,
|
||||
tenantIsolation: false,
|
||||
});
|
||||
const publishEntry = entries.find((e) => e.route.method === "post");
|
||||
const listEntry = entries.find((e) => e.route.method === "get");
|
||||
const deleteEntry = entries.find((e) => e.route.method === "delete");
|
||||
if (!publishEntry || !listEntry || !deleteEntry) {
|
||||
throw new Error("missing entry");
|
||||
}
|
||||
// Drive each handler directly with a fake Hono context.
|
||||
const handle = svc.subscribe("u", () => undefined);
|
||||
const makeCtx = (init: {
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
url: string;
|
||||
body?: unknown;
|
||||
paramFn?: () => Record<string, string>;
|
||||
}) => ({
|
||||
req: {
|
||||
raw: new Request(init.url, {
|
||||
method: init.method,
|
||||
...(init.body !== undefined
|
||||
? {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(init.body),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
param: init.paramFn ?? (() => ({})),
|
||||
json: async () => init.body,
|
||||
},
|
||||
get: () => undefined,
|
||||
json: (body: unknown, status?: number) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: status ?? 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
});
|
||||
// publish
|
||||
const pubRes = (await publishEntry.handler(
|
||||
makeCtx({
|
||||
method: "POST",
|
||||
url: "http://x/publish",
|
||||
body: { topic: "u", payload: {} },
|
||||
}) as never
|
||||
)) as Response;
|
||||
expect(pubRes.status).toBe(200);
|
||||
// list
|
||||
const listRes = (await listEntry.handler(
|
||||
makeCtx({
|
||||
method: "GET",
|
||||
url: "http://x/subscriptions",
|
||||
}) as never
|
||||
)) as Response;
|
||||
expect(listRes.status).toBe(200);
|
||||
// delete
|
||||
const delRes = (await deleteEntry.handler(
|
||||
makeCtx({
|
||||
method: "DELETE",
|
||||
url: `http://x/subscriptions/${handle.id}`,
|
||||
paramFn: () => ({ id: handle.id }),
|
||||
}) as never
|
||||
)) as Response;
|
||||
expect(delRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it("DELETE handler rethrows non-TENANT_MISMATCH service errors", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const realSvc = makeService();
|
||||
const throwingSvc = {
|
||||
...realSvc,
|
||||
unsubscribe: () => {
|
||||
throw new Error("synthetic-service-failure");
|
||||
},
|
||||
};
|
||||
const entries = buildRoutes({
|
||||
service: throwingSvc as never,
|
||||
tenantIsolation: true,
|
||||
});
|
||||
const deleteEntry = entries.find((e) => e.route.method === "delete");
|
||||
if (!deleteEntry) throw new Error("no delete entry");
|
||||
const fakeCtx = {
|
||||
req: {
|
||||
raw: new Request("http://x/event-bus/subscriptions/foo", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
param: () => ({ id: "foo" }),
|
||||
},
|
||||
get: () => undefined,
|
||||
json: (body: unknown, status?: number) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: status ?? 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
};
|
||||
await expect(deleteEntry.handler(fakeCtx as never)).rejects.toThrow(
|
||||
"synthetic-service-failure"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Context } from "hono";
|
||||
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
|
||||
import { extractTenantId } from "../middleware/request-bus.js";
|
||||
import {
|
||||
PublishBodySchema,
|
||||
PublishResultSchema,
|
||||
SubscriptionListSchema,
|
||||
UnsubscribeParamsSchema,
|
||||
UnsubscribeResultSchema,
|
||||
} from "../schemas/routes.js";
|
||||
import type { EventBusService } from "../services/event-bus-service.types.js";
|
||||
|
||||
export interface BuildRoutesOptions {
|
||||
readonly service: EventBusService;
|
||||
readonly tenantIsolation: boolean;
|
||||
}
|
||||
|
||||
interface HandlerCtx {
|
||||
readonly req: { readonly raw: Request };
|
||||
readonly json: (
|
||||
body: unknown,
|
||||
status?: number
|
||||
) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the 3 OpenAPI route entries the plugin mounts on the host
|
||||
* Hono app. Routes share the same tenant-aware execution: every
|
||||
* handler resolves the calling tenant via `extractTenantId(c)` and
|
||||
* delegates to the service with the bound tenant id.
|
||||
*
|
||||
* `requireAuth()` is installed by the plugin's `onInit` step as a
|
||||
* sub-path middleware (`app.use(routePrefix + "/*", requireAuth())`)
|
||||
* rather than via per-route middleware, because the OpenAPI
|
||||
* `RouteBuilder` does not own a `.middleware(...)` slot — see
|
||||
* `plugin/plugin.ts` for the wiring.
|
||||
*/
|
||||
export function buildRoutes(options: BuildRoutesOptions): PluginRouteEntry[] {
|
||||
const { service, tenantIsolation } = options;
|
||||
|
||||
const publishRoute = POST("/publish")
|
||||
.tags("event-bus")
|
||||
.summary("Publish an event to the bus")
|
||||
.body(PublishBodySchema)
|
||||
.response(successResponse(PublishResultSchema))
|
||||
.build();
|
||||
|
||||
const listRoute = GET("/subscriptions")
|
||||
.tags("event-bus")
|
||||
.summary("List active subscriptions for the calling tenant")
|
||||
.response(successResponse(SubscriptionListSchema))
|
||||
.build();
|
||||
|
||||
const deleteRoute = DELETE("/subscriptions/{id}")
|
||||
.tags("event-bus")
|
||||
.summary("Cancel a subscription owned by the calling tenant")
|
||||
.params(UnsubscribeParamsSchema)
|
||||
.response(successResponse(UnsubscribeResultSchema))
|
||||
.build();
|
||||
|
||||
return [
|
||||
{
|
||||
route: publishRoute,
|
||||
handler: makePublishHandler(service, tenantIsolation),
|
||||
},
|
||||
{
|
||||
route: listRoute,
|
||||
handler: makeListHandler(service, tenantIsolation),
|
||||
},
|
||||
{
|
||||
route: deleteRoute,
|
||||
handler: makeDeleteHandler(service, tenantIsolation),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function makePublishHandler(
|
||||
service: EventBusService,
|
||||
tenantIsolation: boolean
|
||||
) {
|
||||
return async (c: HandlerCtx & Context) => {
|
||||
const body = PublishBodySchema.parse(await c.req.json());
|
||||
const tenantId = tenantIsolation ? extractTenantId(c) : undefined;
|
||||
const result = await service.publish(body.topic, body.payload, {
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
...(body.idempotencyKey !== undefined
|
||||
? { idempotencyKey: body.idempotencyKey }
|
||||
: {}),
|
||||
...(body.timeoutMs !== undefined
|
||||
? { timeoutMs: body.timeoutMs }
|
||||
: {}),
|
||||
...(body.traceId !== undefined ? { traceId: body.traceId } : {}),
|
||||
});
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
delivered: result.delivered,
|
||||
dropped: result.dropped,
|
||||
errors: result.errors.map((e) => ({
|
||||
code: e.code,
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function makeListHandler(service: EventBusService, tenantIsolation: boolean) {
|
||||
return async (c: HandlerCtx & Context) => {
|
||||
const tenantId = tenantIsolation ? extractTenantId(c) : undefined;
|
||||
const subscriptions = service.listSubscriptions(
|
||||
tenantId !== undefined ? { tenantId } : {}
|
||||
);
|
||||
return c.json({ success: true, data: { subscriptions } });
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeleteHandler(service: EventBusService, tenantIsolation: boolean) {
|
||||
return async (c: HandlerCtx & Context) => {
|
||||
const reqParam = (
|
||||
c.req as { param: () => Record<string, string> }
|
||||
).param();
|
||||
const params = UnsubscribeParamsSchema.parse(reqParam);
|
||||
const tenantId = tenantIsolation ? extractTenantId(c) : undefined;
|
||||
try {
|
||||
const unsubscribed = service.unsubscribe(params.id, tenantId);
|
||||
if (!unsubscribed) {
|
||||
return c.json(
|
||||
{ success: true, data: { unsubscribed: false } },
|
||||
404
|
||||
);
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
data: { unsubscribed: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof EventBusError &&
|
||||
err.eventBusCode === EVENT_BUS_CODES.TENANT_MISMATCH
|
||||
) {
|
||||
return c.json(
|
||||
{ success: true, data: { unsubscribed: false } },
|
||||
404
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type BuildRoutesOptions, buildRoutes } from "./build-routes.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
BackpressureOptionsSchema,
|
||||
type EventBusPluginOptionsInput,
|
||||
type EventBusPluginOptionsResolved,
|
||||
EventBusPluginOptionsSchema,
|
||||
IdempotencyOptionsSchema,
|
||||
} from "./options.js";
|
||||
export {
|
||||
type ErrorReport,
|
||||
ErrorReportSchema,
|
||||
type PublishBody,
|
||||
PublishBodySchema,
|
||||
type PublishResult,
|
||||
PublishResultSchema,
|
||||
type SubscriptionList,
|
||||
SubscriptionListSchema,
|
||||
type SubscriptionRecord,
|
||||
SubscriptionRecordSchema,
|
||||
type UnsubscribeParams,
|
||||
UnsubscribeParamsSchema,
|
||||
type UnsubscribeResult,
|
||||
UnsubscribeResultSchema,
|
||||
} from "./routes.js";
|
||||
@@ -0,0 +1,106 @@
|
||||
# `options.ts` — Zod schema for `EventBusPluginOptions`
|
||||
|
||||
## Purpose
|
||||
|
||||
Boundary Zod schema validated by `createEventBusPlugin(options)` at
|
||||
the factory entry point. Invalid input produces an
|
||||
`EventBusError(EVENT_BUS.INVALID_INPUT)` carrying the underlying
|
||||
`ZodError` as `cause` — callers always see a typed failure, never an
|
||||
`unknown` parse error from upstream code paths. Schema is `.strict()`
|
||||
so unknown keys fail loudly (typos in option names surface immediately
|
||||
rather than producing silent no-ops).
|
||||
|
||||
## Features
|
||||
|
||||
- Single schema (`EventBusPluginOptionsSchema`) covering the full
|
||||
option surface with sane defaults applied.
|
||||
- Sub-schemas exported for downstream reuse:
|
||||
`BackpressureOptionsSchema`, `IdempotencyOptionsSchema`.
|
||||
- Schema produces a fully-resolved
|
||||
`EventBusPluginOptionsResolved` type (defaults applied) suitable for
|
||||
passing as `PluginDescriptor.defaults`.
|
||||
- Strict mode prevents typo classes like `applySchema: true` (the
|
||||
wrong package's option) from silently being ignored.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EventBusPluginOptionsSchema } from "@suaveplan/api-plugin-event-bus/schemas/options";
|
||||
|
||||
const resolved = EventBusPluginOptionsSchema.parse({});
|
||||
console.log(resolved.routePrefix); // "/event-bus"
|
||||
console.log(resolved.backpressure.policy); // "drop-oldest"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
EventBusPluginOptionsSchema,
|
||||
type EventBusPluginOptionsInput,
|
||||
} from "@suaveplan/api-plugin-event-bus/schemas/options";
|
||||
|
||||
const input: EventBusPluginOptionsInput = {
|
||||
backpressure: { capacity: 32, policy: "throw" },
|
||||
idempotency: { enabled: true, cacheCapacity: 8192 },
|
||||
routePrefix: "/bus",
|
||||
mountRoutes: false,
|
||||
requireAuthForRoutes: false,
|
||||
tenantIsolation: false,
|
||||
interceptors: [
|
||||
async (env, next) => {
|
||||
console.log("emit", env.type);
|
||||
return next();
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const resolved = EventBusPluginOptionsSchema.parse(input);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EventBusPluginOptionsSchema`
|
||||
|
||||
Strict object schema producing `EventBusPluginOptionsResolved`. Fields:
|
||||
|
||||
- `interceptors: Interceptor[]` (default `[]`).
|
||||
- `backpressure: { capacity: number; policy: QueuePolicy }`.
|
||||
- `idempotency: { enabled: boolean; cacheCapacity: number }`.
|
||||
- `routePrefix: string` (default `"/event-bus"`).
|
||||
- `mountRoutes: boolean` (default `true`).
|
||||
- `requireAuthForRoutes: boolean` (default `true`).
|
||||
- `tenantIsolation: boolean` (default `true`).
|
||||
- `maxTopicLength: number` (default `256`).
|
||||
- `logger?: LoggerLike` (optional override).
|
||||
|
||||
### `BackpressureOptionsSchema`
|
||||
|
||||
Stand-alone sub-schema. Exported so consumers building custom plugin
|
||||
factories can reuse the same shape.
|
||||
|
||||
### `IdempotencyOptionsSchema`
|
||||
|
||||
Stand-alone sub-schema.
|
||||
|
||||
### `type EventBusPluginOptionsInput`
|
||||
|
||||
`z.input` of the top-level schema — what callers pass in.
|
||||
|
||||
### `type EventBusPluginOptionsResolved`
|
||||
|
||||
`z.output` of the top-level schema — what the descriptor stores as
|
||||
`defaults` after parsing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `interceptors` array uses `z.custom` (`typeof val === "function"`)
|
||||
rather than `z.function(...)` because Zod 4's `z.function` is
|
||||
builder-style and does not return a `ZodType` directly compatible
|
||||
with `z.array`.
|
||||
- The `routePrefix` regex matches `/auth`'s rule verbatim — start with
|
||||
`/`, no trailing `/` (the literal root `/` is allowed). Sharing the
|
||||
rule across the plugin family means routes mount predictably.
|
||||
- The schema is `.strict()` because the cost of catching typos at the
|
||||
factory boundary far outweighs the convenience of forward-compatible
|
||||
passthrough.
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EventBusPluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("EventBusPluginOptionsSchema", () => {
|
||||
it("resolves all defaults for an empty input", { timeout: 5000 }, () => {
|
||||
const r = EventBusPluginOptionsSchema.parse({});
|
||||
expect(r.routePrefix).toBe("/event-bus");
|
||||
expect(r.mountRoutes).toBe(true);
|
||||
expect(r.requireAuthForRoutes).toBe(true);
|
||||
expect(r.tenantIsolation).toBe(true);
|
||||
expect(r.maxTopicLength).toBe(256);
|
||||
expect(r.backpressure).toEqual({
|
||||
capacity: 1024,
|
||||
policy: "drop-oldest",
|
||||
});
|
||||
expect(r.idempotency).toEqual({
|
||||
enabled: false,
|
||||
cacheCapacity: 4096,
|
||||
});
|
||||
expect(r.interceptors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a custom backpressure block", { timeout: 5000 }, () => {
|
||||
const r = EventBusPluginOptionsSchema.parse({
|
||||
backpressure: { capacity: 8, policy: "throw" },
|
||||
});
|
||||
expect(r.backpressure.capacity).toBe(8);
|
||||
expect(r.backpressure.policy).toBe("throw");
|
||||
});
|
||||
|
||||
it("rejects routePrefix without leading slash", { timeout: 5000 }, () => {
|
||||
const result = EventBusPluginOptionsSchema.safeParse({
|
||||
routePrefix: "missing-slash",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects routePrefix with trailing slash (unless root)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
EventBusPluginOptionsSchema.safeParse({ routePrefix: "/x/" })
|
||||
.success
|
||||
).toBe(false);
|
||||
expect(
|
||||
EventBusPluginOptionsSchema.safeParse({ routePrefix: "/" }).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown top-level keys (strict mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = EventBusPluginOptionsSchema.safeParse({
|
||||
unexpected: true,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-positive capacity", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
EventBusPluginOptionsSchema.safeParse({
|
||||
backpressure: { capacity: 0, policy: "drop-oldest" },
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-function interceptors", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
EventBusPluginOptionsSchema.safeParse({
|
||||
interceptors: [42 as unknown as () => unknown],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects logger lacking required methods", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
EventBusPluginOptionsSchema.safeParse({
|
||||
logger: { info: 42 } as unknown as {
|
||||
debug: (m: string) => void;
|
||||
info: (m: string) => void;
|
||||
warn: (m: string) => void;
|
||||
error: (m: string) => void;
|
||||
},
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a complete LoggerLike", { timeout: 5000 }, () => {
|
||||
const result = EventBusPluginOptionsSchema.safeParse({
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import z from "zod";
|
||||
import { DEFAULT_MAX_TOPIC_LENGTH } from "../topic/topic.js";
|
||||
|
||||
// schema: boundary-only
|
||||
/**
|
||||
* Zod schema for the bounded-queue backpressure block. The full
|
||||
* `QueuePolicy` union from `@suaveplan/events` is preserved.
|
||||
*/
|
||||
export const BackpressureOptionsSchema = z.object({
|
||||
capacity: z.number().int().positive().default(1024),
|
||||
policy: z
|
||||
.enum(["drop-oldest", "drop-newest", "block", "throw"])
|
||||
.default("drop-oldest"),
|
||||
});
|
||||
|
||||
// schema: boundary-only
|
||||
/**
|
||||
* Zod schema for the idempotency block. Disabled by default — the
|
||||
* idempotent wrapper is opt-in either globally or per-subscription.
|
||||
*/
|
||||
export const IdempotencyOptionsSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
cacheCapacity: z.number().int().positive().default(4096),
|
||||
});
|
||||
|
||||
/**
|
||||
* Top-level options schema for `createEventBusPlugin`. Strict — extra
|
||||
* fields fail validation so typos surface at the factory boundary
|
||||
* rather than producing silent no-ops.
|
||||
*/
|
||||
export const EventBusPluginOptionsSchema = z
|
||||
.object({
|
||||
interceptors: z
|
||||
.array(
|
||||
z.custom<(...args: unknown[]) => unknown>(
|
||||
(val) => typeof val === "function",
|
||||
"interceptor must be a function"
|
||||
)
|
||||
)
|
||||
.default([]),
|
||||
backpressure: BackpressureOptionsSchema.default({
|
||||
capacity: 1024,
|
||||
policy: "drop-oldest",
|
||||
}),
|
||||
idempotency: IdempotencyOptionsSchema.default({
|
||||
enabled: false,
|
||||
cacheCapacity: 4096,
|
||||
}),
|
||||
routePrefix: z
|
||||
.string()
|
||||
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
|
||||
message:
|
||||
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
|
||||
})
|
||||
.default("/event-bus"),
|
||||
mountRoutes: z.boolean().default(true),
|
||||
requireAuthForRoutes: z.boolean().default(true),
|
||||
tenantIsolation: z.boolean().default(true),
|
||||
maxTopicLength: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(DEFAULT_MAX_TOPIC_LENGTH),
|
||||
logger: z
|
||||
.custom<{
|
||||
debug: (msg: string, ctx?: unknown) => void;
|
||||
info: (msg: string, ctx?: unknown) => void;
|
||||
warn: (msg: string, ctx?: unknown) => void;
|
||||
error: (msg: string, ctx?: unknown) => void;
|
||||
}>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { info?: unknown }).info === "function",
|
||||
"logger must be a LoggerLike with debug/info/warn/error"
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type EventBusPluginOptionsInput = z.input<
|
||||
typeof EventBusPluginOptionsSchema
|
||||
>;
|
||||
export type EventBusPluginOptionsResolved = z.output<
|
||||
typeof EventBusPluginOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,99 @@
|
||||
# `routes.ts` — Zod schemas for the HTTP route surface
|
||||
|
||||
## Purpose
|
||||
|
||||
Boundary Zod schemas validated by the three OpenAPI routes the plugin
|
||||
optionally mounts: `POST /event-bus/publish`, `GET /event-bus/subscriptions`,
|
||||
and `DELETE /event-bus/subscriptions/:id`. The schemas double as the
|
||||
canonical wire contract — the OpenAPI doc served by the host is
|
||||
generated from them, and downstream client codegen consumes them via
|
||||
the `./schemas/routes` subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- Request body schemas (`PublishBodySchema`) constrain topic syntax
|
||||
AND length.
|
||||
- Response schemas (`PublishResultSchema`, `SubscriptionListSchema`,
|
||||
`UnsubscribeResultSchema`) describe every field the routes return.
|
||||
- All schemas are exported alongside their inferred TypeScript types
|
||||
(`PublishBody`, `PublishResult`, …) so handlers receive narrow,
|
||||
static types.
|
||||
- Error-report sub-schema (`ErrorReportSchema`) preserves the listener
|
||||
failure surface across the wire.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PublishBodySchema } from "@suaveplan/api-plugin-event-bus/schemas/routes";
|
||||
|
||||
const parsed = PublishBodySchema.parse({
|
||||
topic: "order.created",
|
||||
payload: { id: "o1" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
PublishBodySchema,
|
||||
PublishResultSchema,
|
||||
type PublishResult,
|
||||
} from "@suaveplan/api-plugin-event-bus/schemas/routes";
|
||||
|
||||
async function publishViaHttp(req: Request): Promise<PublishResult> {
|
||||
const body = PublishBodySchema.parse(await req.json());
|
||||
const res = await fetch("/event-bus/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return PublishResultSchema.parse(await res.json());
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PublishBodySchema`
|
||||
|
||||
Strict shape: `{ topic, payload?, idempotencyKey?, timeoutMs?, traceId? }`.
|
||||
|
||||
### `PublishResultSchema`
|
||||
|
||||
`{ delivered: number; dropped: boolean; errors: ErrorReport[] }`.
|
||||
|
||||
### `ErrorReportSchema`
|
||||
|
||||
`{ code: string; message: string }`. Surfaces listener exceptions —
|
||||
the upstream `EventsError.code` and `.message` are passed through.
|
||||
|
||||
### `SubscriptionRecordSchema`
|
||||
|
||||
`{ id, topic, tenantId?, createdAt, once, idempotent }`.
|
||||
|
||||
### `SubscriptionListSchema`
|
||||
|
||||
`{ subscriptions: SubscriptionRecord[] }`.
|
||||
|
||||
### `UnsubscribeParamsSchema`
|
||||
|
||||
`{ id }` — used as the OpenAPI `params` schema for the DELETE route.
|
||||
|
||||
### `UnsubscribeResultSchema`
|
||||
|
||||
`{ unsubscribed: boolean }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `payload` is `z.unknown()` because the bus is intentionally
|
||||
payload-agnostic. Publishers / subscribers Zod-validate at their own
|
||||
layers (or fail loudly on the deserialise) when a typed contract is
|
||||
required.
|
||||
- `idempotencyKey` is capped at 256 chars to avoid pathological keys
|
||||
starving the in-memory cache.
|
||||
- `timeoutMs` is capped at 60_000 — beyond that, callers should set up
|
||||
durable retries through their own scheduler rather than parking an
|
||||
HTTP connection.
|
||||
- The route schemas project the topic returned to the client back
|
||||
through `unscopeTopic` at the service layer so HTTP responses never
|
||||
leak the `tenant:<id>:` prefix.
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
PublishBodySchema,
|
||||
PublishResultSchema,
|
||||
SubscriptionListSchema,
|
||||
SubscriptionRecordSchema,
|
||||
UnsubscribeParamsSchema,
|
||||
UnsubscribeResultSchema,
|
||||
} from "./routes.js";
|
||||
|
||||
describe("PublishBodySchema", () => {
|
||||
it("accepts a minimal valid body", { timeout: 5000 }, () => {
|
||||
const r = PublishBodySchema.safeParse({ topic: "order.created" });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the full body", { timeout: 5000 }, () => {
|
||||
const r = PublishBodySchema.safeParse({
|
||||
topic: "order.created",
|
||||
payload: { id: "x" },
|
||||
idempotencyKey: "k1",
|
||||
timeoutMs: 1000,
|
||||
traceId: "t1",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid topic", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
PublishBodySchema.safeParse({ topic: "Order Created" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
PublishBodySchema.safeParse({
|
||||
topic: "x",
|
||||
whatever: true,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects timeoutMs > 60_000", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
PublishBodySchema.safeParse({
|
||||
topic: "x",
|
||||
timeoutMs: 120_000,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PublishResultSchema", () => {
|
||||
it("requires delivered + dropped + errors[]", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
PublishResultSchema.safeParse({
|
||||
delivered: 1,
|
||||
dropped: false,
|
||||
errors: [],
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
PublishResultSchema.safeParse({
|
||||
delivered: 1,
|
||||
errors: [],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionRecord + SubscriptionList", () => {
|
||||
it("accepts a complete record", { timeout: 5000 }, () => {
|
||||
const r = SubscriptionRecordSchema.safeParse({
|
||||
id: "s1",
|
||||
topic: "order.created",
|
||||
tenantId: "t1",
|
||||
createdAt: 1,
|
||||
once: false,
|
||||
idempotent: false,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("list wraps an array", { timeout: 5000 }, () => {
|
||||
const r = SubscriptionListSchema.safeParse({ subscriptions: [] });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnsubscribeParams + UnsubscribeResult", () => {
|
||||
it("params requires non-empty id", { timeout: 5000 }, () => {
|
||||
expect(UnsubscribeParamsSchema.safeParse({ id: "x" }).success).toBe(
|
||||
true
|
||||
);
|
||||
expect(UnsubscribeParamsSchema.safeParse({ id: "" }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("result requires unsubscribed: boolean", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
UnsubscribeResultSchema.safeParse({ unsubscribed: true }).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
UnsubscribeResultSchema.safeParse({ unsubscribed: "yes" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import z from "zod";
|
||||
import { EVENT_TOPIC_PATTERN } from "../topic/topic.js";
|
||||
|
||||
/**
|
||||
* Body schema for `POST /event-bus/publish`. `payload` is opaque
|
||||
* (`unknown`) — producers / consumers Zod-validate at their own layer
|
||||
* if a wire contract is required.
|
||||
*/
|
||||
export const PublishBodySchema = z
|
||||
.object({
|
||||
topic: z.string().min(1).regex(EVENT_TOPIC_PATTERN, {
|
||||
message:
|
||||
"topic must match /^[a-z0-9_-:.]+$/ (lowercase letters, digits, _, -, :, .)",
|
||||
}),
|
||||
payload: z.unknown().optional(),
|
||||
idempotencyKey: z.string().min(1).max(256).optional(),
|
||||
timeoutMs: z.number().int().positive().max(60_000).optional(),
|
||||
traceId: z.string().min(1).max(256).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// schema: boundary-only
|
||||
/** Wire-level error report — surfaces listener failures back to publishers. */
|
||||
export const ErrorReportSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
// schema: boundary-only
|
||||
export const PublishResultSchema = z.object({
|
||||
delivered: z.number().int().nonnegative(),
|
||||
dropped: z.boolean(),
|
||||
errors: z.array(ErrorReportSchema),
|
||||
});
|
||||
|
||||
// schema: boundary-only
|
||||
export const SubscriptionRecordSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
topic: z.string().min(1),
|
||||
tenantId: z.string().optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
once: z.boolean(),
|
||||
idempotent: z.boolean(),
|
||||
});
|
||||
|
||||
// schema: boundary-only
|
||||
export const SubscriptionListSchema = z.object({
|
||||
subscriptions: z.array(SubscriptionRecordSchema),
|
||||
});
|
||||
|
||||
export const UnsubscribeParamsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
// schema: boundary-only
|
||||
export const UnsubscribeResultSchema = z.object({
|
||||
unsubscribed: z.boolean(),
|
||||
});
|
||||
|
||||
export type PublishBody = z.infer<typeof PublishBodySchema>;
|
||||
export type PublishResult = z.infer<typeof PublishResultSchema>;
|
||||
export type SubscriptionRecord = z.infer<typeof SubscriptionRecordSchema>;
|
||||
export type SubscriptionList = z.infer<typeof SubscriptionListSchema>;
|
||||
export type UnsubscribeParams = z.infer<typeof UnsubscribeParamsSchema>;
|
||||
export type UnsubscribeResult = z.infer<typeof UnsubscribeResultSchema>;
|
||||
export type ErrorReport = z.infer<typeof ErrorReportSchema>;
|
||||
@@ -0,0 +1,87 @@
|
||||
# `backpressure.ts` — bounded-queue accounting for the event-bus service
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `@suaveplan/events`' `BoundedQueue` with a small adapter that
|
||||
treats the queue as a pure accounting primitive: a publish reserves a
|
||||
slot, drains it immediately, and the adapter reports whether the
|
||||
publish counts as "dropped" under the configured policy. Keeping this
|
||||
logic in a focused module lets `event-bus-service.ts` stay under the
|
||||
300-line file budget and makes the back-pressure semantics testable in
|
||||
isolation.
|
||||
|
||||
## Features
|
||||
|
||||
- Constructs a `BoundedQueue<unknown>` plus a public `capacity` /
|
||||
`policy` view (the queue itself keeps these private).
|
||||
- `tryEnqueue` translates `EVENTS.QUEUE.OVERFLOW` into
|
||||
`EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)`.
|
||||
- Reports `dropped: true` for `drop-oldest` / `drop-newest` policies
|
||||
when the queue was at capacity before enqueue.
|
||||
- Forwards `AbortSignal` through to the underlying queue.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBackpressureState,
|
||||
tryEnqueue,
|
||||
} from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
|
||||
const state = createBackpressureState({
|
||||
capacity: 8,
|
||||
policy: "drop-oldest",
|
||||
});
|
||||
const outcome = await tryEnqueue(state, { hello: "world" }, undefined);
|
||||
console.log(outcome.dropped);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBackpressureState,
|
||||
tryEnqueue,
|
||||
} from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
|
||||
const state = createBackpressureState({ capacity: 1, policy: "throw" });
|
||||
await tryEnqueue(state, "a", undefined);
|
||||
try {
|
||||
await tryEnqueue(state, "b", undefined);
|
||||
} catch (err) {
|
||||
// EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createBackpressureState(opts: BackpressureOptions): BackpressureState`
|
||||
|
||||
Build the queue + companion accounting struct used by the service.
|
||||
|
||||
### `tryEnqueue(state, payload, signal): Promise<EnqueueOutcome>`
|
||||
|
||||
Reserves a slot, drains it, and returns `{ dropped }`. Throws
|
||||
`EventBusError(EVENT_BUS.BACKPRESSURE_DROPPED)` under `policy: "throw"`
|
||||
when the queue is full.
|
||||
|
||||
### `interface BackpressureState`
|
||||
|
||||
`{ queue: BoundedQueue<unknown>; capacity: number; policy: QueuePolicy }`.
|
||||
|
||||
### `interface EnqueueOutcome`
|
||||
|
||||
`{ dropped: boolean }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- We drain the queue immediately because we use it only for
|
||||
accounting — the actual dispatch is owned by `EventBus.emitAsync`.
|
||||
If the bus ever grows a queue-driven dispatch model, the `dequeue`
|
||||
call disappears and the queue starts carrying the payload.
|
||||
- "would-overflow" is computed **before** `enqueue` because under
|
||||
`drop-oldest` the queue swallows the oldest item synchronously and
|
||||
the post-enqueue size would not reveal the drop.
|
||||
- The thrown `EventBusError` carries `context.statusCode: 503` so the
|
||||
HTTP layer translates back-pressure rejections to "Service
|
||||
Unavailable" automatically.
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EventBusError } from "../error-codes/error-codes.js";
|
||||
import { createBackpressureState, tryEnqueue } from "./backpressure.js";
|
||||
|
||||
describe("createBackpressureState", () => {
|
||||
it("constructs a queue with the supplied capacity + policy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createBackpressureState({
|
||||
capacity: 8,
|
||||
policy: "drop-oldest",
|
||||
});
|
||||
expect(s.capacity).toBe(8);
|
||||
expect(s.policy).toBe("drop-oldest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryEnqueue", () => {
|
||||
it("does not report dropped on the happy path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createBackpressureState({
|
||||
capacity: 4,
|
||||
policy: "drop-oldest",
|
||||
});
|
||||
const out = await tryEnqueue(s, { x: 1 }, undefined);
|
||||
expect(out.dropped).toBe(false);
|
||||
});
|
||||
|
||||
it("reports dropped=true under drop-oldest when the queue is full", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createBackpressureState({
|
||||
capacity: 1,
|
||||
policy: "drop-oldest",
|
||||
});
|
||||
// Inject an item directly so the queue is at capacity before
|
||||
// we measure the next enqueue.
|
||||
await s.queue.enqueue("seed");
|
||||
const out = await tryEnqueue(s, "next", undefined);
|
||||
expect(out.dropped).toBe(true);
|
||||
});
|
||||
|
||||
it("reports dropped=true under drop-newest when the queue is full", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createBackpressureState({
|
||||
capacity: 1,
|
||||
policy: "drop-newest",
|
||||
});
|
||||
await s.queue.enqueue("seed");
|
||||
const out = await tryEnqueue(s, "next", undefined);
|
||||
expect(out.dropped).toBe(true);
|
||||
});
|
||||
|
||||
it("throws BACKPRESSURE_DROPPED under policy=throw on overflow", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createBackpressureState({ capacity: 1, policy: "throw" });
|
||||
await s.queue.enqueue("seed");
|
||||
await expect(tryEnqueue(s, "next", undefined)).rejects.toBeInstanceOf(
|
||||
EventBusError
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates non-overflow errors (e.g. QUEUE.ABORTED on blocked enqueue)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createBackpressureState({ capacity: 1, policy: "block" });
|
||||
// Fill the queue first so the next enqueue blocks and the
|
||||
// abort signal fires before any dequeue drains a slot.
|
||||
await s.queue.enqueue("seed");
|
||||
const ac = new AbortController();
|
||||
const enqueuing = tryEnqueue(s, "next", ac.signal);
|
||||
ac.abort();
|
||||
await expect(enqueuing).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BoundedQueue, EventsError } from "@suaveplan/events";
|
||||
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
|
||||
import type { BackpressureOptions } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Holds the `BoundedQueue` instance alongside its construction-time
|
||||
* `capacity` (which `BoundedQueue` itself keeps private), so the
|
||||
* service can compute "would-overflow" before enqueueing.
|
||||
*/
|
||||
export interface BackpressureState {
|
||||
readonly queue: BoundedQueue<unknown>;
|
||||
readonly capacity: number;
|
||||
readonly policy: BackpressureOptions["policy"];
|
||||
}
|
||||
|
||||
export interface EnqueueOutcome {
|
||||
readonly dropped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the back-pressure state used by `eventBusService.publish`.
|
||||
*/
|
||||
export function createBackpressureState(
|
||||
opts: BackpressureOptions
|
||||
): BackpressureState {
|
||||
return {
|
||||
queue: new BoundedQueue<unknown>({
|
||||
capacity: opts.capacity,
|
||||
policy: opts.policy,
|
||||
}),
|
||||
capacity: opts.capacity,
|
||||
policy: opts.policy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to enqueue a payload through the bounded queue. The queue is
|
||||
* used purely as a back-pressure accounting primitive — we drain the
|
||||
* just-enqueued item synchronously so the actual dispatch happens via
|
||||
* the event bus. The return value reports whether the publish counts
|
||||
* as "dropped" under the policy.
|
||||
*/
|
||||
export async function tryEnqueue(
|
||||
state: BackpressureState,
|
||||
payload: unknown,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<EnqueueOutcome> {
|
||||
const wouldOverflow = state.queue.size >= state.capacity;
|
||||
try {
|
||||
await state.queue.enqueue(payload, signal);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof EventsError &&
|
||||
err.code === "EVENTS.QUEUE.OVERFLOW"
|
||||
) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.BACKPRESSURE_DROPPED,
|
||||
"bounded queue overflow under policy=throw",
|
||||
{
|
||||
cause: err,
|
||||
context: { policy: state.policy, statusCode: 503 },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
state.queue.dequeue();
|
||||
const dropped =
|
||||
wouldOverflow &&
|
||||
(state.policy === "drop-oldest" || state.policy === "drop-newest");
|
||||
return { dropped };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# `event-bus-service.ts` — typed event-bus service
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the canonical `EventBusService` registered in the host
|
||||
`ServiceRegistry` by `createEventBusPlugin`. The service wraps a fresh
|
||||
`EventBus<EventMap>` from `@suaveplan/events`, threads every published
|
||||
payload through a back-pressure-aware bounded queue, supports
|
||||
per-subscription idempotency, and enforces per-tenant topic scoping so
|
||||
isolated multi-tenant workloads can share a single bus instance without
|
||||
leaking events across tenants.
|
||||
|
||||
## Features
|
||||
|
||||
- Pub/sub surface (`publish`, `subscribe`, `unsubscribe`,
|
||||
`listSubscriptions`) plus an escape-hatch `getRawBus()` returning the
|
||||
raw `EventBus<EventMap>` for advanced consumers.
|
||||
- Per-tenant topic scoping when `tenantIsolation` is true — publishes
|
||||
and subscribes auto-prefix with `tenant:<id>:` and `listSubscriptions`
|
||||
filters by calling tenant.
|
||||
- Idempotent subscriptions via the bounded `LimitedSetCache` plus
|
||||
per-publish `idempotencyKey` override.
|
||||
- Bounded-queue back-pressure with the four `QueuePolicy` values
|
||||
(`drop-oldest`, `drop-newest`, `block`, `throw`).
|
||||
- OTel instrumentation for every public path (publish, drop,
|
||||
duplicate, active subscriptions, publish duration).
|
||||
- Listener exceptions surface on `PublishResultRecord.errors` rather
|
||||
than propagating — publishers see a structured report instead of an
|
||||
opaque rejection.
|
||||
- Strict tenant ownership: `unsubscribe(id, tenantId)` throws
|
||||
`EventBusError(EVENT_BUS.TENANT_MISMATCH)` when a foreign tenant
|
||||
attempts to detach another's subscription (the HTTP layer translates
|
||||
this to 404 to avoid leaking ids).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createEventBusService } from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
|
||||
const svc = createEventBusService({
|
||||
interceptors: [],
|
||||
backpressure: { capacity: 1024, policy: "drop-oldest" },
|
||||
idempotency: { enabled: false, cacheCapacity: 4096 },
|
||||
tenantIsolation: true,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
|
||||
const handle = svc.subscribe("order.created", (p) => {
|
||||
console.log("got", p);
|
||||
}, { tenantId: "acme" });
|
||||
|
||||
await svc.publish("order.created", { id: "o1" }, { tenantId: "acme" });
|
||||
handle.unsubscribe();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createEventBusService } from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
|
||||
const svc = createEventBusService({
|
||||
interceptors: [
|
||||
async (env, next) => {
|
||||
const start = Date.now();
|
||||
const result = await next();
|
||||
console.log("emit duration", Date.now() - start, env.type);
|
||||
return result;
|
||||
},
|
||||
],
|
||||
backpressure: { capacity: 16, policy: "throw" },
|
||||
idempotency: { enabled: true, cacheCapacity: 8192 },
|
||||
tenantIsolation: true,
|
||||
maxTopicLength: 128,
|
||||
});
|
||||
|
||||
svc.subscribe("order.completed", (p) => console.log(p), {
|
||||
tenantId: "acme",
|
||||
idempotencyKey: (payload) =>
|
||||
String((payload as { id: string }).id),
|
||||
});
|
||||
|
||||
await svc.publish(
|
||||
"order.completed",
|
||||
{ id: "o1", total: 9.99 },
|
||||
{ tenantId: "acme", idempotencyKey: "o1", timeoutMs: 2000 }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createEventBusService(options): EventBusService`
|
||||
|
||||
Build the canonical service. `options` shape:
|
||||
|
||||
- `interceptors: readonly Interceptor[]` — applied in array order via
|
||||
`bus.use(...)`.
|
||||
- `backpressure: { capacity, policy }`.
|
||||
- `idempotency: { enabled, cacheCapacity }`.
|
||||
- `tenantIsolation: boolean`.
|
||||
- `maxTopicLength: number`.
|
||||
|
||||
### `interface EventBusService`
|
||||
|
||||
- `publish(topic, payload, opts?): Promise<PublishResultRecord>`
|
||||
— validates the topic, applies tenant scoping, enqueues for
|
||||
back-pressure accounting, then calls `bus.emitAsync`.
|
||||
- `subscribe(topic, handler, opts?): SubscriptionHandle` — validates
|
||||
the topic, applies scoping, optionally wraps the handler in
|
||||
idempotency logic.
|
||||
- `unsubscribe(id, tenantId?): boolean` — returns `false` for unknown
|
||||
ids; throws `TENANT_MISMATCH` for foreign owners.
|
||||
- `listSubscriptions(filter?): readonly SubscriptionRecordView[]` —
|
||||
optional `{ tenantId, topic }` filter.
|
||||
- `getRawBus(): EventBus<EventMap>` — escape hatch for advanced
|
||||
consumers needing direct `bus.use(...)` access at runtime.
|
||||
|
||||
### `interface PublishOptions`
|
||||
|
||||
`{ tenantId?, idempotencyKey?, traceId?, signal?, timeoutMs? }`.
|
||||
|
||||
### `interface SubscribeOptions`
|
||||
|
||||
`{ tenantId?, idempotencyKey?(payload), once? }`.
|
||||
|
||||
### `interface PublishResultRecord`
|
||||
|
||||
`{ delivered: number; dropped: boolean; errors: PublishErrorReport[] }`.
|
||||
|
||||
### `interface SubscriptionHandle`
|
||||
|
||||
`{ id, topic, tenantId, unsubscribe() }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bounded queue is treated as an accounting primitive — we
|
||||
`dequeue` immediately after `enqueue` so the actual dispatch flows
|
||||
through `EventBus.emitAsync`. This keeps queue semantics tied to
|
||||
back-pressure while leaving listener execution to the bus.
|
||||
- `subscribe` records every entry in an internal `Map<id, entry>` so
|
||||
`unsubscribe(id)` and `listSubscriptions(filter)` are O(1) per
|
||||
lookup / O(n) per scan respectively.
|
||||
- The default idempotency keying uses `stableHash` (JSON-stringify)
|
||||
which is order-sensitive for objects. Callers requiring canonical
|
||||
hashing should supply an explicit `idempotencyKey` function.
|
||||
- When `tenantIsolation: false`, the service ignores `opts.tenantId`
|
||||
entirely — publishes and subscribes flow on the bare topic. This
|
||||
matches single-tenant deployments where `c.get("tenant")` is always
|
||||
`undefined`.
|
||||
- `publish` wraps non-`EventBusError` rejections from `emitAsync` as
|
||||
`EVENT_BUS.PUBLISH_FAILED` so callers always see a typed error.
|
||||
- `getRawBus` exists for the rare case a downstream plugin needs to
|
||||
install a runtime-defined interceptor that depends on already-running
|
||||
state (e.g. an audit interceptor whose targets are loaded after init).
|
||||
@@ -0,0 +1,369 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
|
||||
import { resetEventBusInstruments } from "../otel/instruments.js";
|
||||
import { createEventBusService } from "./event-bus-service.js";
|
||||
import type { EventBusService } from "./event-bus-service.types.js";
|
||||
|
||||
function makeService(
|
||||
overrides: Partial<{
|
||||
capacity: number;
|
||||
policy: "drop-oldest" | "drop-newest" | "block" | "throw";
|
||||
idempotency: boolean;
|
||||
tenantIsolation: boolean;
|
||||
maxTopicLength: number;
|
||||
}> = {}
|
||||
): EventBusService {
|
||||
resetEventBusInstruments();
|
||||
return createEventBusService({
|
||||
interceptors: [],
|
||||
backpressure: {
|
||||
capacity: overrides.capacity ?? 1024,
|
||||
policy: overrides.policy ?? "drop-oldest",
|
||||
},
|
||||
idempotency: {
|
||||
enabled: overrides.idempotency ?? false,
|
||||
cacheCapacity: 1024,
|
||||
},
|
||||
tenantIsolation: overrides.tenantIsolation ?? true,
|
||||
maxTopicLength: overrides.maxTopicLength ?? 256,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createEventBusService — publish/subscribe basics", () => {
|
||||
it("publish delivers to a matching subscriber", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const received: unknown[] = [];
|
||||
svc.subscribe("order.created", (p) => {
|
||||
received.push(p);
|
||||
});
|
||||
const result = await svc.publish("order.created", { id: 1 });
|
||||
expect(result.delivered).toBe(1);
|
||||
expect(result.dropped).toBe(false);
|
||||
expect(received).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it("publish rejects with INVALID_TOPIC when topic violates pattern", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
await expect(svc.publish("Order Created", {})).rejects.toBeInstanceOf(
|
||||
EventBusError
|
||||
);
|
||||
});
|
||||
|
||||
it("publish rejects with INVALID_TOPIC when topic exceeds maxTopicLength", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const svc = makeService({ maxTopicLength: 8 });
|
||||
try {
|
||||
await svc.publish("a".repeat(9), {});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).context?.reason).toBe(
|
||||
"exceeds-max-length"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("subscribe rejects with INVALID_TOPIC when topic violates pattern", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const svc = makeService();
|
||||
expect(() => svc.subscribe("Bad Topic", () => undefined)).toThrow(
|
||||
EventBusError
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a handle whose unsubscribe stops further delivery", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const received: number[] = [];
|
||||
const handle = svc.subscribe("order.created", () => {
|
||||
received.push(1);
|
||||
});
|
||||
await svc.publish("order.created", {});
|
||||
expect(handle.unsubscribe()).toBe(true);
|
||||
await svc.publish("order.created", {});
|
||||
expect(received.length).toBe(1);
|
||||
});
|
||||
|
||||
it("once=true detaches after the first delivery", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const received: number[] = [];
|
||||
svc.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push(1);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
await svc.publish("order.created", {});
|
||||
await svc.publish("order.created", {});
|
||||
expect(received.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backpressure policies", () => {
|
||||
it("drop-oldest overflow returns dropped=true and increments the counter", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService({ capacity: 1, policy: "drop-oldest" });
|
||||
svc.subscribe("x", () => undefined);
|
||||
const a = await svc.publish("x", {});
|
||||
// Without an intermediate drain the next two publishes overflow
|
||||
// the accounting queue. Even though we dequeue synchronously
|
||||
// after the size check, the size-at-call-time mirrors the
|
||||
// wouldOverflow predicate so subsequent calls report dropped.
|
||||
await svc.publish("x", {});
|
||||
await svc.publish("x", {});
|
||||
expect(a.dropped).toBe(false);
|
||||
// The accounting queue does not "remember" prior drops across
|
||||
// calls (we drain immediately) — so dropped is computed per
|
||||
// call. To force a guaranteed drop, push two concurrent
|
||||
// publishes and let the second see the queue at capacity.
|
||||
const [r1, r2] = await Promise.all([
|
||||
svc.publish("x", {}),
|
||||
svc.publish("x", {}),
|
||||
]);
|
||||
expect(r1.dropped || r2.dropped).toBe(true);
|
||||
});
|
||||
|
||||
it("throw policy rejects with BACKPRESSURE_DROPPED on overflow", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService({ capacity: 1, policy: "throw" });
|
||||
svc.subscribe("x", () => undefined);
|
||||
const [r1, r2] = await Promise.allSettled([
|
||||
svc.publish("x", {}),
|
||||
svc.publish("x", {}),
|
||||
]);
|
||||
const rejected = [r1, r2].find((r) => r.status === "rejected") as
|
||||
| { status: "rejected"; reason: unknown }
|
||||
| undefined;
|
||||
expect(rejected).toBeDefined();
|
||||
expect(rejected?.reason).toBeInstanceOf(EventBusError);
|
||||
expect((rejected?.reason as EventBusError).eventBusCode).toBe(
|
||||
EVENT_BUS_CODES.BACKPRESSURE_DROPPED
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenant isolation", () => {
|
||||
it("tenant A publish does not reach tenant B subscriber", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const received: string[] = [];
|
||||
svc.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push("B");
|
||||
},
|
||||
{ tenantId: "tenant-B" }
|
||||
);
|
||||
await svc.publish("order.created", {}, { tenantId: "tenant-A" });
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it("listSubscriptions filters by calling tenant", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
const svc = makeService();
|
||||
svc.subscribe("a", () => undefined, { tenantId: "ta" });
|
||||
svc.subscribe("a", () => undefined, { tenantId: "ta" });
|
||||
svc.subscribe("a", () => undefined, { tenantId: "tb" });
|
||||
const aSubs = svc.listSubscriptions({ tenantId: "ta" });
|
||||
expect(aSubs.length).toBe(2);
|
||||
expect(aSubs.every((s) => s.tenantId === "ta")).toBe(true);
|
||||
});
|
||||
|
||||
it("unsubscribe across tenants throws TENANT_MISMATCH", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
const svc = makeService();
|
||||
const handle = svc.subscribe("x", () => undefined, {
|
||||
tenantId: "ta",
|
||||
});
|
||||
expect(() => svc.unsubscribe(handle.id, "tb")).toThrow(EventBusError);
|
||||
});
|
||||
|
||||
it("unsubscribe returns false for unknown id", { timeout: 5000 }, () => {
|
||||
const svc = makeService();
|
||||
expect(svc.unsubscribe("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("with tenantIsolation=false, publishes ignore tenantId scoping", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService({ tenantIsolation: false });
|
||||
const received: string[] = [];
|
||||
svc.subscribe(
|
||||
"shared",
|
||||
() => {
|
||||
received.push("hit");
|
||||
},
|
||||
{ tenantId: "ignored" }
|
||||
);
|
||||
await svc.publish("shared", {}, { tenantId: "someone-else" });
|
||||
expect(received).toEqual(["hit"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("idempotent subscriptions", () => {
|
||||
it("idempotent subscription fires handler once for duplicate publish", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const seen: number[] = [];
|
||||
svc.subscribe(
|
||||
"evt",
|
||||
() => {
|
||||
seen.push(1);
|
||||
},
|
||||
{ idempotencyKey: (p) => String((p as { k: string }).k) }
|
||||
);
|
||||
await svc.publish("evt", { k: "same" });
|
||||
await svc.publish("evt", { k: "same" });
|
||||
await svc.publish("evt", { k: "diff" });
|
||||
expect(seen.length).toBe(2);
|
||||
});
|
||||
|
||||
it("plugin-level idempotency.enabled defaults to stableHash keying", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService({ idempotency: true });
|
||||
const seen: number[] = [];
|
||||
svc.subscribe("evt", () => {
|
||||
seen.push(1);
|
||||
});
|
||||
await svc.publish("evt", { same: true });
|
||||
await svc.publish("evt", { same: true });
|
||||
expect(seen.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error propagation", () => {
|
||||
it("listener exceptions surface on PublishResult.errors", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
svc.subscribe("evt", () => {
|
||||
throw new Error("listener-failed");
|
||||
});
|
||||
const result = await svc.publish("evt", {});
|
||||
expect(result.errors.length).toBe(1);
|
||||
expect(result.errors[0]?.message).toContain("listener-failed");
|
||||
});
|
||||
|
||||
it("publish wraps non-EventBusError throws as PUBLISH_FAILED", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
// Drive a deterministic emit failure by passing an
|
||||
// already-aborted signal so emitAsync rejects.
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await svc.publish("evt", {}, { signal: ac.signal });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).eventBusCode).toBe(
|
||||
EVENT_BUS_CODES.PUBLISH_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getRawBus exposes the underlying EventBus", { timeout: 5000 }, () => {
|
||||
const svc = makeService();
|
||||
expect(svc.getRawBus()).toBeDefined();
|
||||
// Round-trip through the raw API.
|
||||
const bus = svc.getRawBus();
|
||||
expect(typeof bus.emit).toBe("function");
|
||||
});
|
||||
|
||||
it("interceptors run around emitAsync", { timeout: 30_000 }, async () => {
|
||||
resetEventBusInstruments();
|
||||
const seen: string[] = [];
|
||||
const svc = createEventBusService({
|
||||
interceptors: [
|
||||
async (env, next) => {
|
||||
seen.push(`before:${env.type}`);
|
||||
const out = await next();
|
||||
seen.push(`after:${env.type}`);
|
||||
return out;
|
||||
},
|
||||
],
|
||||
backpressure: { capacity: 1024, policy: "drop-oldest" },
|
||||
idempotency: { enabled: false, cacheCapacity: 32 },
|
||||
tenantIsolation: false,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
svc.subscribe("x", () => undefined);
|
||||
await svc.publish("x", {});
|
||||
expect(seen[0]).toBe("before:x");
|
||||
expect(seen[1]).toBe("after:x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSubscriptions", () => {
|
||||
it("filters by topic when supplied", { timeout: 30_000 }, () => {
|
||||
const svc = makeService();
|
||||
svc.subscribe("a", () => undefined);
|
||||
svc.subscribe("b", () => undefined);
|
||||
expect(svc.listSubscriptions({ topic: "a" }).length).toBe(1);
|
||||
expect(svc.listSubscriptions({ topic: "b" }).length).toBe(1);
|
||||
expect(svc.listSubscriptions().length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("publish rethrows EventBusError from interceptor verbatim", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetEventBusInstruments();
|
||||
const svc = createEventBusService({
|
||||
interceptors: [
|
||||
() => {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.INVALID_INPUT,
|
||||
"from-interceptor"
|
||||
);
|
||||
},
|
||||
],
|
||||
backpressure: { capacity: 1024, policy: "drop-oldest" },
|
||||
idempotency: { enabled: false, cacheCapacity: 32 },
|
||||
tenantIsolation: false,
|
||||
maxTopicLength: 256,
|
||||
});
|
||||
svc.subscribe("x", () => undefined);
|
||||
try {
|
||||
await svc.publish("x", {});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).eventBusCode).toBe(
|
||||
EVENT_BUS_CODES.INVALID_INPUT
|
||||
);
|
||||
expect((err as EventBusError).message).toBe("from-interceptor");
|
||||
}
|
||||
});
|
||||
|
||||
it("once=true handler followed by manual handle.unsubscribe is idempotent", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = makeService();
|
||||
const handle = svc.subscribe("evt", () => undefined, { once: true });
|
||||
await svc.publish("evt", {});
|
||||
// After the once-fire the handle has self-detached; an explicit
|
||||
// unsubscribe must still complete without crashing and return
|
||||
// false because the entry is no longer in the registry.
|
||||
expect(handle.unsubscribe()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { EventBus, type EventMap, type Listener } from "@suaveplan/events";
|
||||
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
|
||||
import { getEventBusInstruments } from "../otel/instruments.js";
|
||||
import { assertValidTopic, scopeTopic } from "../topic/topic.js";
|
||||
import {
|
||||
type BackpressureState,
|
||||
createBackpressureState,
|
||||
tryEnqueue,
|
||||
} from "./backpressure.js";
|
||||
import type {
|
||||
CreateEventBusServiceOptions,
|
||||
EventBusService,
|
||||
PublishOptions,
|
||||
PublishResultRecord,
|
||||
SubscribeOptions,
|
||||
SubscriptionEntry,
|
||||
SubscriptionFilter,
|
||||
SubscriptionHandle,
|
||||
SubscriptionRecordView,
|
||||
} from "./event-bus-service.types.js";
|
||||
import {
|
||||
buildEmitOptions,
|
||||
nextSubscriptionId,
|
||||
projectSubscription,
|
||||
recordPublishMetric,
|
||||
stableHash,
|
||||
toErrorReport,
|
||||
} from "./helpers.js";
|
||||
import { LimitedSetCache } from "./idempotency-cache.js";
|
||||
|
||||
export type {
|
||||
CreateEventBusServiceOptions,
|
||||
EventBusService,
|
||||
PublishOptions,
|
||||
PublishResultRecord,
|
||||
SubscribeOptions,
|
||||
SubscriptionFilter,
|
||||
SubscriptionHandle,
|
||||
SubscriptionRecordView,
|
||||
};
|
||||
|
||||
interface ServiceState {
|
||||
readonly bus: EventBus<EventMap>;
|
||||
readonly queueState: BackpressureState;
|
||||
readonly subscriptions: Map<string, SubscriptionEntry>;
|
||||
readonly sharedCache: LimitedSetCache;
|
||||
readonly tenantIsolation: boolean;
|
||||
readonly maxTopicLength: number;
|
||||
readonly idempotencyEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the canonical `EventBusService` implementation. Wraps a
|
||||
* fresh `EventBus<EventMap>` from `@suaveplan/events`, installs every
|
||||
* configured interceptor, and threads a `BoundedQueue` in front of
|
||||
* the listener dispatch so back-pressure semantics live with the
|
||||
* service rather than every call site.
|
||||
*/
|
||||
export function createEventBusService(
|
||||
options: CreateEventBusServiceOptions
|
||||
): EventBusService {
|
||||
const bus: EventBus<EventMap> = new EventBus();
|
||||
for (const interceptor of options.interceptors) {
|
||||
bus.use(interceptor);
|
||||
}
|
||||
const state: ServiceState = {
|
||||
bus,
|
||||
queueState: createBackpressureState(options.backpressure),
|
||||
subscriptions: new Map(),
|
||||
sharedCache: new LimitedSetCache(options.idempotency.cacheCapacity),
|
||||
tenantIsolation: options.tenantIsolation,
|
||||
maxTopicLength: options.maxTopicLength,
|
||||
idempotencyEnabled: options.idempotency.enabled,
|
||||
};
|
||||
return {
|
||||
publish: (topic, payload, opts) => publish(state, topic, payload, opts),
|
||||
subscribe: (topic, handler, opts) =>
|
||||
subscribe(state, topic, handler, opts),
|
||||
unsubscribe: (id, tenantId) => unsubscribeById(state, id, tenantId),
|
||||
listSubscriptions: (filter) => listSubscriptions(state, filter),
|
||||
getRawBus: () => bus,
|
||||
};
|
||||
}
|
||||
|
||||
async function publish(
|
||||
state: ServiceState,
|
||||
topic: string,
|
||||
payload: unknown,
|
||||
opts: PublishOptions = {}
|
||||
): Promise<PublishResultRecord> {
|
||||
assertValidTopic(topic, { maxLength: state.maxTopicLength });
|
||||
const tenantId = state.tenantIsolation ? opts.tenantId : undefined;
|
||||
const scoped = scopeTopic(topic, tenantId);
|
||||
const outcome = await tryEnqueue(state.queueState, payload, opts.signal);
|
||||
if (outcome.dropped) {
|
||||
getEventBusInstruments().droppedTotal.add(1, {
|
||||
policy: state.queueState.policy,
|
||||
});
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const emitResult = await state.bus.emitAsync(
|
||||
scoped,
|
||||
payload,
|
||||
buildEmitOptions(opts)
|
||||
);
|
||||
recordPublishMetric(topic, "success", Date.now() - start);
|
||||
return {
|
||||
delivered: emitResult.delivered,
|
||||
dropped: outcome.dropped,
|
||||
errors: emitResult.errors.map(toErrorReport),
|
||||
};
|
||||
} catch (err) {
|
||||
recordPublishMetric(topic, "error", Date.now() - start);
|
||||
if (err instanceof EventBusError) throw err;
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.PUBLISH_FAILED,
|
||||
"EventBus.emitAsync rejected",
|
||||
{ cause: err, context: { topic, statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
state: ServiceState,
|
||||
topic: string,
|
||||
handler: Listener<unknown>,
|
||||
opts: SubscribeOptions = {}
|
||||
): SubscriptionHandle {
|
||||
assertValidTopic(topic, { maxLength: state.maxTopicLength });
|
||||
const tenantId = state.tenantIsolation ? opts.tenantId : undefined;
|
||||
const scoped = scopeTopic(topic, tenantId);
|
||||
const id = nextSubscriptionId();
|
||||
const useIdempotent =
|
||||
opts.idempotencyKey !== undefined || state.idempotencyEnabled;
|
||||
const keyOf = opts.idempotencyKey ?? stableHash;
|
||||
// `detach` is assigned below — captured by the once-wrapped
|
||||
// listener through closure so the wrapper can self-detach on its
|
||||
// single fire. Use a NonNullable assignment dance rather than a
|
||||
// placeholder lambda so the closure does not carry an unreachable
|
||||
// arrow function.
|
||||
let detach!: () => void;
|
||||
const baseListener: Listener<unknown> =
|
||||
opts.once === true
|
||||
? (payload) => {
|
||||
detach();
|
||||
return handler(payload);
|
||||
}
|
||||
: handler;
|
||||
const instrumentedHandler: Listener<unknown> = (payload) => {
|
||||
if (useIdempotent) {
|
||||
const key = keyOf(payload);
|
||||
if (state.sharedCache.has(key)) {
|
||||
getEventBusInstruments().duplicatesTotal.add(1);
|
||||
return undefined;
|
||||
}
|
||||
state.sharedCache.add(key);
|
||||
}
|
||||
return baseListener(payload);
|
||||
};
|
||||
const detachListener = state.bus.on(scoped, instrumentedHandler);
|
||||
// `detach` runs at most once per subscription — `unsubscribeById`
|
||||
// short-circuits on subsequent calls because the map entry is
|
||||
// already gone, and `once: true` re-entry is impossible because
|
||||
// the once-wrapping listener detaches before invoking the user
|
||||
// handler. So `delete(id)` always returns true here and the
|
||||
// counter decrement is unconditional.
|
||||
detach = () => {
|
||||
detachListener();
|
||||
state.subscriptions.delete(id);
|
||||
getEventBusInstruments().subscriptionsActive.add(-1);
|
||||
};
|
||||
const entry: SubscriptionEntry = {
|
||||
id,
|
||||
topic,
|
||||
scopedTopic: scoped,
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
listener: instrumentedHandler,
|
||||
detach,
|
||||
createdAt: Date.now(),
|
||||
once: opts.once === true,
|
||||
idempotent: useIdempotent,
|
||||
};
|
||||
state.subscriptions.set(id, entry);
|
||||
getEventBusInstruments().subscriptionsActive.add(1);
|
||||
return {
|
||||
id,
|
||||
topic,
|
||||
tenantId,
|
||||
// The handle captures its own tenantId at subscribe time so
|
||||
// calling `unsubscribeById(state, id, tenantId)` here is always
|
||||
// the owning tenant — `TENANT_MISMATCH` is unreachable through
|
||||
// this code path, so the call is unwrapped (no try/catch).
|
||||
unsubscribe: () => unsubscribeById(state, id, tenantId),
|
||||
};
|
||||
}
|
||||
|
||||
function unsubscribeById(
|
||||
state: ServiceState,
|
||||
id: string,
|
||||
tenantId?: string
|
||||
): boolean {
|
||||
const entry = state.subscriptions.get(id);
|
||||
if (entry === undefined) return false;
|
||||
if (state.tenantIsolation && tenantId !== entry.tenantId) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.TENANT_MISMATCH,
|
||||
"subscription id is owned by a different tenant",
|
||||
{
|
||||
context: {
|
||||
id,
|
||||
callerTenantId: tenantId,
|
||||
ownerTenantId: entry.tenantId,
|
||||
statusCode: 404,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
entry.detach();
|
||||
return true;
|
||||
}
|
||||
|
||||
function listSubscriptions(
|
||||
state: ServiceState,
|
||||
filter: SubscriptionFilter = {}
|
||||
): readonly SubscriptionRecordView[] {
|
||||
const out: SubscriptionRecordView[] = [];
|
||||
for (const entry of state.subscriptions.values()) {
|
||||
if (
|
||||
state.tenantIsolation &&
|
||||
filter.tenantId !== undefined &&
|
||||
entry.tenantId !== filter.tenantId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (filter.topic !== undefined && entry.topic !== filter.topic) {
|
||||
continue;
|
||||
}
|
||||
out.push(projectSubscription(entry));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {
|
||||
EventBus,
|
||||
EventMap,
|
||||
IdempotencyCache,
|
||||
Interceptor,
|
||||
Listener,
|
||||
} from "@suaveplan/events";
|
||||
import type {
|
||||
BackpressureOptions,
|
||||
IdempotencyOptions,
|
||||
} from "../types/types.js";
|
||||
|
||||
/** Wire-level error report — pairs with `ErrorReportSchema`. */
|
||||
export interface PublishErrorReport {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface PublishOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly idempotencyKey?: string;
|
||||
readonly traceId?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PublishResultRecord {
|
||||
readonly delivered: number;
|
||||
readonly dropped: boolean;
|
||||
readonly errors: readonly PublishErrorReport[];
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly idempotencyKey?: (payload: unknown) => string;
|
||||
readonly once?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionHandle {
|
||||
readonly id: string;
|
||||
readonly topic: string;
|
||||
readonly tenantId: string | undefined;
|
||||
unsubscribe(): boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionRecordView {
|
||||
readonly id: string;
|
||||
readonly topic: string;
|
||||
readonly tenantId?: string;
|
||||
readonly createdAt: number;
|
||||
readonly once: boolean;
|
||||
readonly idempotent: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionFilter {
|
||||
readonly tenantId?: string;
|
||||
readonly topic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal record stored in the subscription registry. Holds the
|
||||
* tenant scope, the listener attached to the underlying bus, and the
|
||||
* detach callback returned by `bus.on(...)`.
|
||||
*/
|
||||
export interface SubscriptionEntry {
|
||||
readonly id: string;
|
||||
readonly topic: string;
|
||||
readonly scopedTopic: string;
|
||||
readonly tenantId?: string;
|
||||
readonly listener: Listener<unknown>;
|
||||
readonly detach: () => void;
|
||||
readonly createdAt: number;
|
||||
readonly once: boolean;
|
||||
readonly idempotent: boolean;
|
||||
}
|
||||
|
||||
export interface EventBusService {
|
||||
publish(
|
||||
topic: string,
|
||||
payload: unknown,
|
||||
opts?: PublishOptions
|
||||
): Promise<PublishResultRecord>;
|
||||
subscribe(
|
||||
topic: string,
|
||||
handler: Listener<unknown>,
|
||||
opts?: SubscribeOptions
|
||||
): SubscriptionHandle;
|
||||
unsubscribe(handleId: string, tenantId?: string): boolean;
|
||||
listSubscriptions(
|
||||
filter?: SubscriptionFilter
|
||||
): readonly SubscriptionRecordView[];
|
||||
getRawBus(): EventBus<EventMap>;
|
||||
}
|
||||
|
||||
export interface CreateEventBusServiceOptions {
|
||||
readonly interceptors: readonly Interceptor[];
|
||||
readonly backpressure: BackpressureOptions;
|
||||
readonly idempotency: IdempotencyOptions;
|
||||
readonly tenantIsolation: boolean;
|
||||
readonly maxTopicLength: number;
|
||||
}
|
||||
|
||||
export type { IdempotencyCache };
|
||||
@@ -0,0 +1,110 @@
|
||||
# `helpers.ts` — small utilities used by `event-bus-service.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Holds the pure helpers extracted from the main service body so the
|
||||
service itself stays under the 300-line cap and each helper can be
|
||||
unit-tested in isolation. The helpers cover id generation, payload
|
||||
hashing, error projection, subscription projection, emit-option
|
||||
assembly, and OTel metric emission.
|
||||
|
||||
## Features
|
||||
|
||||
- `nextSubscriptionId` — collision-resistant id generator.
|
||||
- `stableHash` — deterministic-enough payload hash for default
|
||||
idempotency keying.
|
||||
- `toErrorReport` — converts upstream listener errors into the
|
||||
wire-level shape advertised on `PublishResultRecord.errors`.
|
||||
- `projectSubscription` — internal entry → public read-only view.
|
||||
- `buildEmitOptions` — sparse builder for `EventBus.emitAsync`
|
||||
options.
|
||||
- `recordPublishMetric` — one-call OTel emission for every publish.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
nextSubscriptionId,
|
||||
stableHash,
|
||||
} from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
|
||||
const id = nextSubscriptionId();
|
||||
const key = stableHash({ x: 1 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildEmitOptions,
|
||||
recordPublishMetric,
|
||||
toErrorReport,
|
||||
} from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
|
||||
const emitOptions = buildEmitOptions({
|
||||
signal: AbortSignal.timeout(2000),
|
||||
timeoutMs: 2000,
|
||||
idempotencyKey: "k1",
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
// ... emit
|
||||
recordPublishMetric("order.created", "success", Date.now() - t0);
|
||||
} catch (err) {
|
||||
recordPublishMetric("order.created", "error", Date.now() - t0);
|
||||
const report = toErrorReport(err);
|
||||
console.error(report.code, report.message);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `nextSubscriptionId(): string`
|
||||
|
||||
Returns `` `sub_${n}_${base36}` `` ids; the base36 suffix keeps two
|
||||
collocated service instances from colliding when the module-level
|
||||
counter desyncs across workers.
|
||||
|
||||
### `stableHash(payload: unknown): string`
|
||||
|
||||
JSON-stringifies the payload (falling back to `String(payload)` on
|
||||
throw). Used as the default idempotency-key derivation when a
|
||||
subscription is registered without an explicit `idempotencyKey`
|
||||
function.
|
||||
|
||||
### `toErrorReport(err: unknown): PublishErrorReport`
|
||||
|
||||
Projects a listener error into `{ code, message }`. Preserves
|
||||
`EventsError.code`; falls back to `EVENT_BUS.LISTENER_FAILED`.
|
||||
|
||||
### `projectSubscription(entry): SubscriptionRecordView`
|
||||
|
||||
Strips internal fields and conditionally omits `tenantId` when
|
||||
absent.
|
||||
|
||||
### `buildEmitOptions(opts): EmitAsyncOptions`
|
||||
|
||||
Sparse builder — only sets fields the caller explicitly provided.
|
||||
|
||||
### `recordPublishMetric(topic, outcome, durationMs): void`
|
||||
|
||||
Increments `event_bus.published_total{outcome,topic_root}` and
|
||||
records the publish duration on `event_bus.publish_duration_ms`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `nextSubscriptionId`'s counter resets on module reload (test
|
||||
isolation) and per-process. Production collisions are guarded by
|
||||
the random suffix, which gives ~36⁸ ≈ 2.8e12 ids per counter
|
||||
value.
|
||||
- `stableHash` uses `JSON.stringify` which is not key-order stable
|
||||
across all engines for objects with non-enumerable properties.
|
||||
Callers requiring canonical hashing supply an explicit
|
||||
`idempotencyKey` function at subscribe time.
|
||||
- `toErrorReport`'s `EVENT_BUS.LISTENER_FAILED` envelope is a
|
||||
surface-level code — it is NOT registered in `EVENT_BUS_CODES`
|
||||
because nothing in the package throws it (it only appears in
|
||||
`PublishResultRecord.errors`).
|
||||
- `projectSubscription` returns a `readonly` shape so consumers
|
||||
cannot mutate the record they got from `listSubscriptions`.
|
||||
@@ -0,0 +1,122 @@
|
||||
import { EVENTS_CODES, EventsError } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { SubscriptionEntry } from "./event-bus-service.types.js";
|
||||
import {
|
||||
buildEmitOptions,
|
||||
nextSubscriptionId,
|
||||
projectSubscription,
|
||||
recordPublishMetric,
|
||||
stableHash,
|
||||
toErrorReport,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("nextSubscriptionId", () => {
|
||||
it("returns a unique id on every call", { timeout: 5000 }, () => {
|
||||
const a = nextSubscriptionId();
|
||||
const b = nextSubscriptionId();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.startsWith("sub_")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stableHash", () => {
|
||||
it("returns deterministic JSON for plain objects", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(stableHash({ a: 1, b: 2 })).toBe(JSON.stringify({ a: 1, b: 2 }));
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", { timeout: 5000 }, () => {
|
||||
expect(stableHash(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to String(payload) on JSON failure", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
expect(stableHash(circular)).toBe(String(circular));
|
||||
});
|
||||
});
|
||||
|
||||
describe("toErrorReport", () => {
|
||||
it("preserves EventsError code", { timeout: 5000 }, () => {
|
||||
const err = new EventsError(EVENTS_CODES["LISTENER.THREW"], "boom");
|
||||
const report = toErrorReport(err);
|
||||
expect(report.code).toBe("EVENTS.LISTENER.THREW");
|
||||
expect(report.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("uses LISTENER_FAILED envelope for plain Error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const report = toErrorReport(new Error("oops"));
|
||||
expect(report.code).toBe("EVENT_BUS.LISTENER_FAILED");
|
||||
expect(report.message).toBe("oops");
|
||||
});
|
||||
|
||||
it("stringifies non-Error throwables", { timeout: 5000 }, () => {
|
||||
const report = toErrorReport("literal");
|
||||
expect(report.code).toBe("EVENT_BUS.LISTENER_FAILED");
|
||||
expect(report.message).toBe("literal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectSubscription", () => {
|
||||
const base: SubscriptionEntry = {
|
||||
id: "s",
|
||||
topic: "t",
|
||||
scopedTopic: "t",
|
||||
listener: () => undefined,
|
||||
detach: () => undefined,
|
||||
createdAt: 0,
|
||||
once: false,
|
||||
idempotent: false,
|
||||
};
|
||||
|
||||
it("includes tenantId when present", { timeout: 5000 }, () => {
|
||||
const view = projectSubscription({ ...base, tenantId: "ta" });
|
||||
expect(view.tenantId).toBe("ta");
|
||||
});
|
||||
|
||||
it("omits tenantId when absent", { timeout: 5000 }, () => {
|
||||
const view = projectSubscription(base);
|
||||
expect("tenantId" in view).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEmitOptions", () => {
|
||||
it("returns an empty object when nothing is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(buildEmitOptions({})).toEqual({});
|
||||
});
|
||||
|
||||
it("forwards signal/timeoutMs/traceId", { timeout: 5000 }, () => {
|
||||
const ac = new AbortController();
|
||||
const out = buildEmitOptions({
|
||||
signal: ac.signal,
|
||||
timeoutMs: 1234,
|
||||
traceId: "trace-1",
|
||||
});
|
||||
expect(out?.signal).toBe(ac.signal);
|
||||
expect(out?.timeoutMs).toBe(1234);
|
||||
expect(out?.traceId).toBe("trace-1");
|
||||
});
|
||||
|
||||
it("falls back from traceId to idempotencyKey", { timeout: 5000 }, () => {
|
||||
const out = buildEmitOptions({ idempotencyKey: "k1" });
|
||||
expect(out?.traceId).toBe("k1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordPublishMetric", () => {
|
||||
it("does not throw for either outcome", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
recordPublishMetric("order.created", "success", 1)
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
recordPublishMetric("order.created", "error", 1)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { type EventBus, type EventMap, EventsError } from "@suaveplan/events";
|
||||
import { getEventBusInstruments } from "../otel/instruments.js";
|
||||
import { topicRoot } from "../topic/topic.js";
|
||||
import type {
|
||||
PublishErrorReport,
|
||||
PublishOptions,
|
||||
SubscriptionEntry,
|
||||
SubscriptionRecordView,
|
||||
} from "./event-bus-service.types.js";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
/**
|
||||
* Generate a fresh subscription id. Combines an incrementing counter
|
||||
* with a random suffix so ids are still unique across collocated
|
||||
* service instances (e.g. concurrent test runs in the same worker).
|
||||
*/
|
||||
export function nextSubscriptionId(): string {
|
||||
counter += 1;
|
||||
return `sub_${counter}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default idempotency-key derivation when a subscription is registered
|
||||
* with the plugin-level `idempotency.enabled` flag but no explicit
|
||||
* `idempotencyKey` function. Stringifies the payload via JSON and
|
||||
* falls back to `String(payload)` when JSON-stringify throws (e.g.
|
||||
* circular references).
|
||||
*/
|
||||
export function stableHash(payload: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(payload) ?? "";
|
||||
} catch {
|
||||
return String(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a listener-error from the underlying `EventBus.emitAsync`
|
||||
* call into the wire-level `PublishErrorReport` returned via
|
||||
* `PublishResultRecord.errors`. Preserves the upstream `EventsError`
|
||||
* code when present; falls back to a generic
|
||||
* `EVENT_BUS.LISTENER_FAILED` envelope otherwise.
|
||||
*/
|
||||
export function toErrorReport(err: unknown): PublishErrorReport {
|
||||
if (err instanceof EventsError) {
|
||||
// `err.message` is the i18n key (e.g. `error.events.listener.threw`);
|
||||
// the underlying listener throw is carried on `.cause`. Prefer the
|
||||
// original cause's message so downstream surfaces see the human
|
||||
// text the listener emitted rather than the i18n placeholder.
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
const message = cause instanceof Error ? cause.message : err.message;
|
||||
return { code: String(err.code), message };
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { code: "EVENT_BUS.LISTENER_FAILED", message: err.message };
|
||||
}
|
||||
return { code: "EVENT_BUS.LISTENER_FAILED", message: String(err) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project an internal subscription entry to the public read-only
|
||||
* record view returned by `listSubscriptions`. Conditionally includes
|
||||
* `tenantId` so callers in single-tenant mode get a clean record
|
||||
* without a noisy `tenantId: undefined` field.
|
||||
*/
|
||||
export function projectSubscription(
|
||||
entry: SubscriptionEntry
|
||||
): SubscriptionRecordView {
|
||||
if (entry.tenantId !== undefined) {
|
||||
return {
|
||||
id: entry.id,
|
||||
topic: entry.topic,
|
||||
tenantId: entry.tenantId,
|
||||
createdAt: entry.createdAt,
|
||||
once: entry.once,
|
||||
idempotent: entry.idempotent,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
topic: entry.topic,
|
||||
createdAt: entry.createdAt,
|
||||
once: entry.once,
|
||||
idempotent: entry.idempotent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `EmitAsyncOptions` object forwarded to
|
||||
* `EventBus.emitAsync`. Omits fields the caller did not set so the
|
||||
* downstream defaulting logic (`timeoutMs === undefined` skips the
|
||||
* timer entirely) keeps working.
|
||||
*/
|
||||
export function buildEmitOptions(
|
||||
opts: PublishOptions
|
||||
): Parameters<EventBus<EventMap>["emitAsync"]>[2] {
|
||||
const out: { signal?: AbortSignal; timeoutMs?: number; traceId?: string } =
|
||||
{};
|
||||
if (opts.signal !== undefined) out.signal = opts.signal;
|
||||
if (opts.timeoutMs !== undefined) out.timeoutMs = opts.timeoutMs;
|
||||
const traceId = opts.traceId ?? opts.idempotencyKey;
|
||||
if (traceId !== undefined) out.traceId = traceId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the per-publish OTel counter + histogram in one place so the
|
||||
* service body stays focused on dispatch.
|
||||
*/
|
||||
export function recordPublishMetric(
|
||||
topic: string,
|
||||
outcome: "success" | "error",
|
||||
durationMs: number
|
||||
): void {
|
||||
const instruments = getEventBusInstruments();
|
||||
instruments.publishedTotal.add(1, {
|
||||
outcome,
|
||||
topic_root: topicRoot(topic),
|
||||
});
|
||||
instruments.publishDurationMs.record(durationMs);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# `idempotency-cache.ts` — bounded set-based idempotency cache
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the default `IdempotencyCache` implementation used by
|
||||
`eventBusService` to suppress duplicate publishes. The cache satisfies
|
||||
the `IdempotencyCache` interface from `@suaveplan/events` (`has` /
|
||||
`add`), so any operator that needs a different storage strategy
|
||||
(Redis, in-memory LRU with TTL, etc.) drops in their own implementation
|
||||
without changing the service contract.
|
||||
|
||||
The implementation is intentionally minimal: a `Set` plus a parallel
|
||||
insertion-order array gives O(1) `has`/`add` with bounded memory. The
|
||||
default capacity (4096 keys) tolerates several minutes of moderate
|
||||
publish traffic before evicting the oldest keys.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `IdempotencyCache` from `@suaveplan/events`.
|
||||
- O(1) lookup, O(1) amortised insert.
|
||||
- Hard upper-bound on memory — the oldest key is evicted when the
|
||||
population exceeds `capacity`.
|
||||
- Validates `capacity` is a positive integer at construction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LimitedSetCache } from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
|
||||
const cache = new LimitedSetCache(1024);
|
||||
cache.add("evt-1");
|
||||
cache.has("evt-1"); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LimitedSetCache } from "@suaveplan/api-plugin-event-bus/services/event-bus-service";
|
||||
import { idempotent } from "@suaveplan/events";
|
||||
|
||||
const cache = new LimitedSetCache(2);
|
||||
const handler = idempotent(
|
||||
(payload) => console.log("once", payload),
|
||||
{ keyOf: (p) => String((p as { id: string }).id), cache }
|
||||
);
|
||||
handler({ id: "a" }); // logs
|
||||
handler({ id: "a" }); // suppressed
|
||||
handler({ id: "b" });
|
||||
handler({ id: "c" }); // adds "c", evicts "a"
|
||||
handler({ id: "a" }); // logs again — "a" was evicted
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class LimitedSetCache implements IdempotencyCache`
|
||||
|
||||
- `constructor(capacity: number)` — throws `RangeError` if `capacity`
|
||||
is not a positive integer.
|
||||
- `has(key: string): boolean`.
|
||||
- `add(key: string): void` — no-op when the key is already present;
|
||||
evicts oldest when the population exceeds `capacity`.
|
||||
- `size: number` — current key count.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Eviction is FIFO, not strictly LRU — re-`add`ing an existing key
|
||||
does not promote it. This matches the existing
|
||||
`@suaveplan/events.idempotent` cache contract (`has` is `O(1)`,
|
||||
`add` is `O(1)`) and avoids the doubly-linked-list overhead an LRU
|
||||
would incur for the same upper-memory guarantee.
|
||||
- The dead-store guard (`if (oldest !== undefined)`) is required
|
||||
because `Array.prototype.shift` types as `T | undefined` even when
|
||||
the call site knows the array is non-empty.
|
||||
- The cache lives in-process — restarting the API server clears it.
|
||||
Operators needing durable dedup wire a Redis-backed
|
||||
`IdempotencyCache` via a future plugin option (out of scope for
|
||||
this change).
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LimitedSetCache } from "./idempotency-cache.js";
|
||||
|
||||
describe("LimitedSetCache", () => {
|
||||
it("rejects non-positive capacity at construction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new LimitedSetCache(0)).toThrow(RangeError);
|
||||
expect(() => new LimitedSetCache(-1)).toThrow(RangeError);
|
||||
expect(() => new LimitedSetCache(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("has() returns false for unknown keys", { timeout: 5000 }, () => {
|
||||
const c = new LimitedSetCache(4);
|
||||
expect(c.has("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("add() makes has() return true", { timeout: 5000 }, () => {
|
||||
const c = new LimitedSetCache(4);
|
||||
c.add("k1");
|
||||
expect(c.has("k1")).toBe(true);
|
||||
expect(c.size).toBe(1);
|
||||
});
|
||||
|
||||
it("add() is a no-op for duplicate keys", { timeout: 5000 }, () => {
|
||||
const c = new LimitedSetCache(4);
|
||||
c.add("k1");
|
||||
c.add("k1");
|
||||
c.add("k1");
|
||||
expect(c.size).toBe(1);
|
||||
});
|
||||
|
||||
it("evicts oldest beyond capacity", { timeout: 5000 }, () => {
|
||||
const c = new LimitedSetCache(2);
|
||||
c.add("a");
|
||||
c.add("b");
|
||||
c.add("c");
|
||||
expect(c.has("a")).toBe(false);
|
||||
expect(c.has("b")).toBe(true);
|
||||
expect(c.has("c")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IdempotencyCache } from "@suaveplan/events";
|
||||
|
||||
/**
|
||||
* Bounded LRU-ish idempotency cache used by `eventBusService` when
|
||||
* `idempotency.enabled` is set or a subscription provides an
|
||||
* `idempotencyKey` derivation function. Keys are inserted in
|
||||
* encounter order and evicted oldest-first when the population grows
|
||||
* past `capacity`.
|
||||
*
|
||||
* Implementation is intentionally simple — the cache is in-process
|
||||
* and per-plugin-instance. An out-of-process / shared cache would
|
||||
* plug in here by satisfying `IdempotencyCache` and being passed in
|
||||
* via a future option.
|
||||
*/
|
||||
export class LimitedSetCache implements IdempotencyCache {
|
||||
private readonly capacity: number;
|
||||
private readonly entries = new Set<string>();
|
||||
private readonly order: string[] = [];
|
||||
|
||||
public constructor(capacity: number) {
|
||||
if (!Number.isInteger(capacity) || capacity <= 0) {
|
||||
throw new RangeError(
|
||||
`LimitedSetCache: capacity must be a positive integer; got ${capacity}`
|
||||
);
|
||||
}
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
public has(key: string): boolean {
|
||||
return this.entries.has(key);
|
||||
}
|
||||
|
||||
public add(key: string): void {
|
||||
if (this.entries.has(key)) return;
|
||||
this.entries.add(key);
|
||||
this.order.push(key);
|
||||
if (this.order.length > this.capacity) {
|
||||
// `order.length > capacity` (> 0) guarantees `shift()`
|
||||
// returns a real string; the non-null assertion encodes
|
||||
// that invariant without an unreachable runtime branch.
|
||||
const oldest = this.order.shift() as string;
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import "./registry-augmentation.js";
|
||||
|
||||
export {
|
||||
type BackpressureState,
|
||||
createBackpressureState,
|
||||
type EnqueueOutcome,
|
||||
tryEnqueue,
|
||||
} from "./backpressure.js";
|
||||
export {
|
||||
type CreateEventBusServiceOptions,
|
||||
createEventBusService,
|
||||
type EventBusService,
|
||||
type PublishOptions,
|
||||
type PublishResultRecord,
|
||||
type SubscribeOptions,
|
||||
type SubscriptionFilter,
|
||||
type SubscriptionHandle,
|
||||
type SubscriptionRecordView,
|
||||
} from "./event-bus-service.js";
|
||||
export type {
|
||||
PublishErrorReport,
|
||||
SubscriptionEntry,
|
||||
} from "./event-bus-service.types.js";
|
||||
export {
|
||||
buildEmitOptions,
|
||||
nextSubscriptionId,
|
||||
projectSubscription,
|
||||
recordPublishMetric,
|
||||
stableHash,
|
||||
toErrorReport,
|
||||
} from "./helpers.js";
|
||||
export { LimitedSetCache } from "./idempotency-cache.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# `registry-augmentation.ts` — `ServiceRegistry` slot for `eventBusService`
|
||||
|
||||
## Purpose
|
||||
|
||||
Augments `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
interface to advertise the `eventBusService` slot the plugin
|
||||
registers at boot. Downstream plugins typed against the augmented
|
||||
registry see `ctx.getService("eventBusService")` resolve to
|
||||
`EventBusService | undefined` without any cast. Mirrors the pattern
|
||||
used by `@suaveplan/api-plugin-auth`'s `registry-augmentation.ts`.
|
||||
|
||||
The module re-declares the host-side slots (`app`, `openAPIRegistry`,
|
||||
`logger`, `eventBus`, `clock`) as a cumulative augmentation so the
|
||||
plugin's `onInit(ctx)` can resolve them with full typing even when the
|
||||
consumer has not separately imported `@suaveplan/api`. TypeScript
|
||||
merges duplicate `interface ServiceRegistry { ... }` declarations, so
|
||||
the duplication is safe alongside `@suaveplan/api`'s own
|
||||
augmentation.
|
||||
|
||||
## Features
|
||||
|
||||
- Side-effect-only module — importing it surfaces the augmentation to
|
||||
every consumer.
|
||||
- Re-declares the host-owned slots so the plugin compiles in
|
||||
isolation.
|
||||
- Marks `openAPIRegistry` as optional — the plugin tolerates a host
|
||||
that does not register an OpenAPI registry by mounting routes on
|
||||
the Hono app only.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-event-bus/services/registry-augmentation";
|
||||
|
||||
// elsewhere — typed without any cast
|
||||
const bus = ctx.getService("eventBusService");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { EventBusService } from "@suaveplan/api-plugin-event-bus";
|
||||
import "@suaveplan/api-plugin-event-bus/services/registry-augmentation";
|
||||
|
||||
function consume(getService: <K extends string>(name: K) => unknown) {
|
||||
// Augmentation makes the literal "eventBusService" resolve
|
||||
// cleanly against the registry interface.
|
||||
return getService("eventBusService") as EventBusService | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module exports nothing at runtime. The declaration:
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
eventBusService: EventBusService;
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry?: OpenAPIRegistry;
|
||||
logger: LoggerLike;
|
||||
eventBus: EventBusLike;
|
||||
clock: Clock;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
is the entire surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- An empty `export {};` keeps the file in module scope — TypeScript
|
||||
treats files with only `declare` statements as scripts otherwise.
|
||||
- The host-owned re-declarations match the canonical slot names used
|
||||
in `@suaveplan/api`'s own augmentation. If those slot names change
|
||||
upstream, this file must follow.
|
||||
- `openAPIRegistry?: OpenAPIRegistry` differs from `@suaveplan/api`'s
|
||||
required declaration on purpose — the event-bus plugin tolerates
|
||||
hosts without OpenAPI by mounting routes on the Hono app alone.
|
||||
TypeScript's module-merging treats the optional declaration as a
|
||||
widening, so consumers importing both modules still see the
|
||||
required form.
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the
|
||||
* `eventBusService` slot on
|
||||
* `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
* interface so downstream plugins typed against the augmented
|
||||
* registry see `ctx.getService("eventBusService")` resolve to
|
||||
* `EventBusService | undefined` without any cast.
|
||||
*
|
||||
* Importing this module for its side effects (which the package root
|
||||
* barrel does automatically) is sufficient — every consumer of
|
||||
* `@suaveplan/api-plugin-event-bus` gets typed `eventBusService`
|
||||
* access.
|
||||
*
|
||||
* The host-side slots (`app`, `openAPIRegistry`, `logger`,
|
||||
* `eventBus`, `clock`) are owned by `@suaveplan/api`'s own
|
||||
* registry-augmentation module. We re-declare them here as a
|
||||
* cumulative augmentation so the event-bus plugin's `onInit(ctx)`
|
||||
* can resolve them with full typing even when the consumer has not
|
||||
* explicitly imported `@suaveplan/api` (e.g. during isolated
|
||||
* unit-tests of the plugin). TypeScript merges duplicate
|
||||
* declarations, so this is safe alongside `@suaveplan/api`'s
|
||||
* declarations.
|
||||
*/
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { EventBusService } from "./event-bus-service.types.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
eventBusService: EventBusService;
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry?: OpenAPIRegistry;
|
||||
logger: LoggerLike;
|
||||
eventBus: EventBusLike;
|
||||
clock: Clock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type { AssertTopicOptions } from "./topic.js";
|
||||
export {
|
||||
assertValidTopic,
|
||||
DEFAULT_MAX_TOPIC_LENGTH,
|
||||
EVENT_TOPIC_PATTERN,
|
||||
scopeTopic,
|
||||
TENANT_TOPIC_PREFIX,
|
||||
topicRoot,
|
||||
unscopeTopic,
|
||||
} from "./topic.js";
|
||||
119
packages/api-stack/api/api-plugin-event-bus/src/topic/topic.md
Normal file
119
packages/api-stack/api/api-plugin-event-bus/src/topic/topic.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# `topic.ts` — topic validation + tenant scoping
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure functions for validating event-bus topics and projecting them into
|
||||
their tenant-scoped form. Every `publish` and `subscribe` going through
|
||||
`eventBusService` calls `assertValidTopic` at the boundary, and the
|
||||
scoped middleware handle uses `scopeTopic` / `unscopeTopic` to keep
|
||||
per-tenant traffic isolated. Pulling this logic into its own module
|
||||
keeps the service-layer code small and lets test code compute scoped
|
||||
topics without spinning up the full plugin.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical `EVENT_TOPIC_PATTERN` (`/^[a-z0-9_\-:.]+$/`) — single
|
||||
source of truth for topic syntax.
|
||||
- `assertValidTopic(topic, { maxLength })` — throws `EventBusError`
|
||||
(`INVALID_TOPIC`) with `context.reason ∈ "empty" | "exceeds-max-length"
|
||||
| "pattern-mismatch"`.
|
||||
- `scopeTopic(topic, tenantId?)` — prefixes with `tenant:<id>:` when
|
||||
a tenant id is supplied.
|
||||
- `unscopeTopic(scoped, tenantId?)` — inverse, returns `null` if the
|
||||
scoped string does not belong to the supplied tenant.
|
||||
- `topicRoot(topic)` — derives the OTel `topic_root` label.
|
||||
- `DEFAULT_MAX_TOPIC_LENGTH` (256) and `TENANT_TOPIC_PREFIX`
|
||||
(`tenant:`) constants for downstream reuse.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertValidTopic,
|
||||
scopeTopic,
|
||||
} from "@suaveplan/api-plugin-event-bus/topic/topic";
|
||||
|
||||
assertValidTopic("order.created"); // OK
|
||||
assertValidTopic("Order Created"); // throws EventBusError(INVALID_TOPIC)
|
||||
|
||||
const scoped = scopeTopic("order.created", "acme");
|
||||
// "tenant:acme:order.created"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_MAX_TOPIC_LENGTH,
|
||||
scopeTopic,
|
||||
topicRoot,
|
||||
unscopeTopic,
|
||||
} from "@suaveplan/api-plugin-event-bus/topic/topic";
|
||||
|
||||
function project(records: { topic: string }[], tenantId: string) {
|
||||
return records
|
||||
.map((r) => {
|
||||
const original = unscopeTopic(r.topic, tenantId);
|
||||
if (original === null) return null;
|
||||
return { topic: original, root: topicRoot(original) };
|
||||
})
|
||||
.filter((x): x is { topic: string; root: string } => x !== null);
|
||||
}
|
||||
|
||||
// Use the constant when tuning per-app limits — e.g. an app whose
|
||||
// service mesh truncates labels at 128 may pass `maxLength: 128` to
|
||||
// `assertValidTopic`.
|
||||
console.log(DEFAULT_MAX_TOPIC_LENGTH);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EVENT_TOPIC_PATTERN: RegExp`
|
||||
|
||||
Frozen regex; lowercase letters, digits, `_`, `-`, `:`, `.`.
|
||||
|
||||
### `DEFAULT_MAX_TOPIC_LENGTH: 256`
|
||||
|
||||
### `TENANT_TOPIC_PREFIX: "tenant:"`
|
||||
|
||||
### `assertValidTopic(topic: string, options?: { maxLength?: number }): void`
|
||||
|
||||
Throws `EventBusError(EVENT_BUS.INVALID_TOPIC)` with a `context.reason`
|
||||
discriminator on failure. Returns `void` on success.
|
||||
|
||||
### `scopeTopic(topic: string, tenantId?: string): string`
|
||||
|
||||
Returns `tenant:<id>:<topic>` when `tenantId` is non-empty; otherwise
|
||||
returns `topic` verbatim. Validates `tenantId` against a stricter
|
||||
regex (no `:` allowed) so callers cannot smuggle a foreign tenant
|
||||
prefix through the parameter — a malformed id throws
|
||||
`EventBusError(EVENT_BUS.INVALID_TOPIC)` with
|
||||
`context.reason === "tenant-id-pattern-mismatch"`.
|
||||
|
||||
### `unscopeTopic(scoped: string, tenantId?: string): string | null`
|
||||
|
||||
Returns the original topic if `scoped` matches the tenant prefix.
|
||||
Returns `null` if the prefix does not match (used by
|
||||
`listSubscriptions` to filter foreign records). When `tenantId` is
|
||||
absent the function is the identity.
|
||||
|
||||
### `topicRoot(topic: string): string`
|
||||
|
||||
Returns the substring before the first `.` or `:`. Used as the
|
||||
`topic_root` label on `event_bus.published_total`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pattern intentionally excludes uppercase letters and whitespace
|
||||
so topics stay safe for use as URL path segments, OTel attribute
|
||||
values, and message-queue routing keys without further escaping.
|
||||
- Tenant ids carried through `scopeTopic` use a stricter sub-regex
|
||||
(no `:` separator) — otherwise a tenant id of `"t1:other"` could
|
||||
emulate `tenant:t1:other:topic` for another tenant.
|
||||
- `topicRoot` truncates at the first separator rather than producing
|
||||
high-cardinality labels — OTel limits the number of distinct label
|
||||
values per metric, so we keep this deliberately coarse.
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EventBusError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
assertValidTopic,
|
||||
DEFAULT_MAX_TOPIC_LENGTH,
|
||||
EVENT_TOPIC_PATTERN,
|
||||
scopeTopic,
|
||||
TENANT_TOPIC_PREFIX,
|
||||
topicRoot,
|
||||
unscopeTopic,
|
||||
} from "./topic.js";
|
||||
|
||||
describe("EVENT_TOPIC_PATTERN", () => {
|
||||
it("accepts canonical lower-snake-colon-dot topics", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const ok of [
|
||||
"order.created",
|
||||
"user_signed_in",
|
||||
"ns:domain.event",
|
||||
"a",
|
||||
"x-y-z",
|
||||
]) {
|
||||
expect(EVENT_TOPIC_PATTERN.test(ok)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects uppercase, whitespace, and exotic punctuation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const bad of [
|
||||
"Order.Created",
|
||||
"order created",
|
||||
"order#created",
|
||||
"order/created",
|
||||
"",
|
||||
]) {
|
||||
expect(EVENT_TOPIC_PATTERN.test(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertValidTopic", () => {
|
||||
it("returns silently for a valid topic", { timeout: 5000 }, () => {
|
||||
expect(() => assertValidTopic("order.created")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws INVALID_TOPIC with reason 'empty' for empty topic", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
assertValidTopic("");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).eventBusCode).toBe(
|
||||
"EVENT_BUS.INVALID_TOPIC"
|
||||
);
|
||||
expect((err as EventBusError).context?.reason).toBe("empty");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_TOPIC with reason 'exceeds-max-length'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
assertValidTopic("a".repeat(9), { maxLength: 8 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).context?.reason).toBe(
|
||||
"exceeds-max-length"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_TOPIC with reason 'pattern-mismatch'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
assertValidTopic("Order Created");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).context?.reason).toBe(
|
||||
"pattern-mismatch"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-string input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertValidTopic(42 as unknown as string);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventBusError);
|
||||
expect((err as EventBusError).context?.reason).toBe("empty");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses DEFAULT_MAX_TOPIC_LENGTH when no override is given", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(DEFAULT_MAX_TOPIC_LENGTH).toBe(256);
|
||||
expect(() =>
|
||||
assertValidTopic("a".repeat(DEFAULT_MAX_TOPIC_LENGTH))
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertValidTopic("a".repeat(DEFAULT_MAX_TOPIC_LENGTH + 1))
|
||||
).toThrow(EventBusError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scopeTopic / unscopeTopic", () => {
|
||||
it("scopeTopic prefixes with tenant id when tenantId is provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(scopeTopic("order.created", "t1")).toBe(
|
||||
`${TENANT_TOPIC_PREFIX}t1:order.created`
|
||||
);
|
||||
});
|
||||
|
||||
it("scopeTopic returns topic verbatim when no tenantId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(scopeTopic("order.created")).toBe("order.created");
|
||||
expect(scopeTopic("order.created", "")).toBe("order.created");
|
||||
});
|
||||
|
||||
it("scopeTopic rejects tenantId containing ':'", { timeout: 5000 }, () => {
|
||||
expect(() => scopeTopic("ok", "t1:other")).toThrow(EventBusError);
|
||||
});
|
||||
|
||||
it("unscopeTopic returns null for foreign tenant prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scoped = scopeTopic("order.created", "t1");
|
||||
expect(unscopeTopic(scoped, "t2")).toBeNull();
|
||||
});
|
||||
|
||||
it("unscopeTopic returns the original topic for matching tenant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scoped = scopeTopic("order.created", "t1");
|
||||
expect(unscopeTopic(scoped, "t1")).toBe("order.created");
|
||||
});
|
||||
|
||||
it("unscopeTopic with no tenantId is identity", { timeout: 5000 }, () => {
|
||||
expect(unscopeTopic("anything")).toBe("anything");
|
||||
expect(unscopeTopic("anything", "")).toBe("anything");
|
||||
});
|
||||
});
|
||||
|
||||
describe("topicRoot", () => {
|
||||
it("returns prefix before first '.' or ':'", { timeout: 5000 }, () => {
|
||||
expect(topicRoot("order.created")).toBe("order");
|
||||
expect(topicRoot("ns:order.created")).toBe("ns");
|
||||
expect(topicRoot("simple")).toBe("simple");
|
||||
});
|
||||
});
|
||||
124
packages/api-stack/api/api-plugin-event-bus/src/topic/topic.ts
Normal file
124
packages/api-stack/api/api-plugin-event-bus/src/topic/topic.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Canonical regex every event-bus topic must satisfy.
|
||||
*
|
||||
* - Lowercase ASCII letters and digits.
|
||||
* - `_`, `-`, `:`, `.` separators (mirror common pub/sub conventions —
|
||||
* `:` for namespace, `.` for sub-event, `-` for word separation).
|
||||
* - At least one character.
|
||||
*/
|
||||
export const EVENT_TOPIC_PATTERN = /^[a-z0-9_\-:.]+$/u;
|
||||
|
||||
/** Default maximum topic length when the plugin does not override. */
|
||||
export const DEFAULT_MAX_TOPIC_LENGTH = 256;
|
||||
|
||||
/** Marker used to prefix tenant-scoped topics. */
|
||||
export const TENANT_TOPIC_PREFIX = "tenant:";
|
||||
|
||||
export interface AssertTopicOptions {
|
||||
readonly maxLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a topic against {@link EVENT_TOPIC_PATTERN} and the optional
|
||||
* length limit. Throws {@link EventBusError} (`INVALID_TOPIC`) on
|
||||
* failure with a descriptive `context.reason`.
|
||||
*/
|
||||
export function assertValidTopic(
|
||||
topic: string,
|
||||
options: AssertTopicOptions = {}
|
||||
): void {
|
||||
const maxLength = options.maxLength ?? DEFAULT_MAX_TOPIC_LENGTH;
|
||||
if (typeof topic !== "string" || topic.length === 0) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.INVALID_TOPIC,
|
||||
"topic must be a non-empty string",
|
||||
{ context: { topic, reason: "empty", statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
if (topic.length > maxLength) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.INVALID_TOPIC,
|
||||
`topic exceeds maxTopicLength=${maxLength}`,
|
||||
{
|
||||
context: {
|
||||
topic,
|
||||
maxLength,
|
||||
reason: "exceeds-max-length",
|
||||
statusCode: 400,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!EVENT_TOPIC_PATTERN.test(topic)) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.INVALID_TOPIC,
|
||||
"topic violates EVENT_TOPIC_PATTERN",
|
||||
{
|
||||
context: {
|
||||
topic,
|
||||
pattern: EVENT_TOPIC_PATTERN.source,
|
||||
reason: "pattern-mismatch",
|
||||
statusCode: 400,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the tenant-scoped form of a topic. When `tenantId` is provided
|
||||
* and non-empty, the result is `tenant:<id>:<topic>`. Otherwise the
|
||||
* topic is returned verbatim.
|
||||
*
|
||||
* The tenant id itself is validated against the same regex (sans the
|
||||
* `:` and `.` separators) so a malicious caller cannot smuggle a
|
||||
* cross-tenant prefix in via `tenantId`.
|
||||
*/
|
||||
export function scopeTopic(topic: string, tenantId?: string): string {
|
||||
if (tenantId === undefined || tenantId.length === 0) return topic;
|
||||
assertValidTenantId(tenantId);
|
||||
return `${TENANT_TOPIC_PREFIX}${tenantId}:${topic}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link scopeTopic}. When `scoped` was produced by
|
||||
* `scopeTopic(topic, tenantId)`, returns `topic`. Otherwise returns
|
||||
* `null`. Used by `listSubscriptions` to project tenant-internal
|
||||
* topics back to the caller.
|
||||
*/
|
||||
export function unscopeTopic(scoped: string, tenantId?: string): string | null {
|
||||
if (tenantId === undefined || tenantId.length === 0) return scoped;
|
||||
const prefix = `${TENANT_TOPIC_PREFIX}${tenantId}:`;
|
||||
if (!scoped.startsWith(prefix)) return null;
|
||||
return scoped.slice(prefix.length);
|
||||
}
|
||||
|
||||
const TENANT_ID_PATTERN = /^[A-Za-z0-9_\-.]+$/u;
|
||||
|
||||
function assertValidTenantId(tenantId: string): void {
|
||||
if (!TENANT_ID_PATTERN.test(tenantId)) {
|
||||
throw new EventBusError(
|
||||
EVENT_BUS_CODES.INVALID_TOPIC,
|
||||
"tenantId violates EVENT_TOPIC_PATTERN (sans ':')",
|
||||
{
|
||||
context: {
|
||||
tenantId,
|
||||
reason: "tenant-id-pattern-mismatch",
|
||||
statusCode: 400,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `topic_root` label used in OTel metrics — the substring
|
||||
* before the first `.` / `:` separator, or the whole topic when no
|
||||
* separator is present.
|
||||
*/
|
||||
export function topicRoot(topic: string): string {
|
||||
const idx = topic.search(/[.:]/u);
|
||||
return idx === -1 ? topic : topic.slice(0, idx);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
BackpressureOptions,
|
||||
EventBusPluginConfig,
|
||||
EventBusPluginOptions,
|
||||
IdempotencyOptions,
|
||||
} from "./types.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# `types.ts` — public option / config interfaces
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the two top-level shapes consumed by external callers of the
|
||||
plugin: `EventBusPluginOptions` (what `createEventBusPlugin` accepts)
|
||||
and `EventBusPluginConfig` (the post-Zod-parse resolved form stored on
|
||||
the descriptor). Sub-shapes `BackpressureOptions` and
|
||||
`IdempotencyOptions` are exported so users assembling option objects
|
||||
get full IntelliSense without importing schema types from `zod`.
|
||||
|
||||
## Features
|
||||
|
||||
- Plain TypeScript interfaces (no Zod inference) — consumable from
|
||||
type-only contexts that cannot tolerate runtime imports.
|
||||
- `EventBusPluginOptions` is partial-friendly; every field is optional
|
||||
so the simplest call site is `createEventBusPlugin({})`.
|
||||
- `EventBusPluginConfig` is the fully-resolved shape with every
|
||||
default applied.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { EventBusPluginOptions } from "@suaveplan/api-plugin-event-bus/types/types";
|
||||
|
||||
const opts: EventBusPluginOptions = { mountRoutes: false };
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
BackpressureOptions,
|
||||
EventBusPluginOptions,
|
||||
IdempotencyOptions,
|
||||
} from "@suaveplan/api-plugin-event-bus/types/types";
|
||||
|
||||
const backpressure: BackpressureOptions = {
|
||||
capacity: 64,
|
||||
policy: "throw",
|
||||
};
|
||||
|
||||
const idempotency: IdempotencyOptions = {
|
||||
enabled: true,
|
||||
cacheCapacity: 4096,
|
||||
};
|
||||
|
||||
const opts: EventBusPluginOptions = {
|
||||
backpressure,
|
||||
idempotency,
|
||||
routePrefix: "/bus",
|
||||
requireAuthForRoutes: false,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface BackpressureOptions`
|
||||
|
||||
`{ capacity: number; policy: "drop-oldest" | "drop-newest" | "block" | "throw" }`.
|
||||
|
||||
### `interface IdempotencyOptions`
|
||||
|
||||
`{ enabled: boolean; cacheCapacity: number }`.
|
||||
|
||||
### `interface EventBusPluginOptions`
|
||||
|
||||
All fields optional. Validated by `EventBusPluginOptionsSchema`.
|
||||
|
||||
### `interface EventBusPluginConfig`
|
||||
|
||||
All fields required (defaults applied). Stored as the plugin
|
||||
descriptor's `defaults`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- These interfaces deliberately use `readonly` arrays / objects so
|
||||
downstream code cannot mutate caller-supplied configuration in
|
||||
place; the Zod parse step also `Object.freeze`s the resolved value
|
||||
via Zod's default behaviour.
|
||||
- The runtime policy union mirrors `QueuePolicy` from
|
||||
`@suaveplan/events` exactly — keeping the strings literal-typed at
|
||||
the boundary means option consumers get accurate auto-completion
|
||||
without depending on the events package's runtime export.
|
||||
- Cross-package types ARE allowed to import `Interceptor` from
|
||||
`@suaveplan/events` here because we are intentionally re-exposing
|
||||
that library's contract.
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Interceptor } from "@suaveplan/events";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
export interface BackpressureOptions {
|
||||
readonly capacity: number;
|
||||
readonly policy: "drop-oldest" | "drop-newest" | "block" | "throw";
|
||||
}
|
||||
|
||||
export interface IdempotencyOptions {
|
||||
readonly enabled: boolean;
|
||||
readonly cacheCapacity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level options accepted by `createEventBusPlugin`. Validated by
|
||||
* `EventBusPluginOptionsSchema` at the factory boundary.
|
||||
*/
|
||||
export interface EventBusPluginOptions {
|
||||
readonly interceptors?: readonly Interceptor[];
|
||||
readonly backpressure?: Partial<BackpressureOptions>;
|
||||
readonly idempotency?: Partial<IdempotencyOptions>;
|
||||
/** URL prefix where the plugin mounts its 3 routes. Default `/event-bus`. */
|
||||
readonly routePrefix?: string;
|
||||
/** Mount HTTP routes. Default `true`. */
|
||||
readonly mountRoutes?: boolean;
|
||||
/** Gate HTTP routes behind `requireAuth()`. Default `true`. */
|
||||
readonly requireAuthForRoutes?: boolean;
|
||||
/** Auto-prefix topics with `tenant:<id>:`. Default `true`. */
|
||||
readonly tenantIsolation?: boolean;
|
||||
/** Maximum topic length (chars). Default `256`. */
|
||||
readonly maxTopicLength?: number;
|
||||
/** Override logger. Default: `PluginContext.logger`. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully-resolved options after Zod parse — every default has been
|
||||
* applied. The plugin descriptor's `defaults` field carries this shape.
|
||||
*/
|
||||
export interface EventBusPluginConfig {
|
||||
readonly interceptors: readonly Interceptor[];
|
||||
readonly backpressure: BackpressureOptions;
|
||||
readonly idempotency: IdempotencyOptions;
|
||||
readonly routePrefix: string;
|
||||
readonly mountRoutes: boolean;
|
||||
readonly requireAuthForRoutes: boolean;
|
||||
readonly tenantIsolation: boolean;
|
||||
readonly maxTopicLength: number;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createEventBusPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { EventBusService } from "../../src/services/event-bus-service.types.js";
|
||||
import type { EventBusPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
async function bootHttp(): Promise<{
|
||||
app: OpenAPIHono;
|
||||
service: EventBusService;
|
||||
}> {
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "ev-test", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
// For HTTP tests we keep `requireAuthForRoutes: false` so the
|
||||
// tests don't require @suaveplan/api-plugin-auth at boot.
|
||||
const plugin = createEventBusPlugin({
|
||||
requireAuthForRoutes: false,
|
||||
mountRoutes: true,
|
||||
});
|
||||
// Tenant slot — populate before the request-bus middleware reads it.
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("tenant" as never, { id: "acme" } as never);
|
||||
await next();
|
||||
});
|
||||
const ctx = createPluginContext<EventBusPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as EventBusPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
return {
|
||||
app,
|
||||
service: services.get("eventBusService") as EventBusService,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HTTP routes end-to-end against the real plugin lifecycle", () => {
|
||||
it("POST /event-bus/publish returns delivered + dropped", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { app, service } = await bootHttp();
|
||||
let received: unknown = null;
|
||||
service.subscribe(
|
||||
"order.created",
|
||||
(p) => {
|
||||
received = p;
|
||||
},
|
||||
{ tenantId: "acme" }
|
||||
);
|
||||
const res = await app.request("/event-bus/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
topic: "order.created",
|
||||
payload: { id: "o1" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status).toBeLessThan(400);
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
data: { delivered: number; dropped: boolean };
|
||||
};
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.delivered).toBe(1);
|
||||
expect(body.data.dropped).toBe(false);
|
||||
expect(received).toEqual({ id: "o1" });
|
||||
});
|
||||
|
||||
it("POST /event-bus/publish rejects invalid topic", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { app } = await bootHttp();
|
||||
const res = await app.request("/event-bus/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ topic: "Bad Topic", payload: {} }),
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("GET /event-bus/subscriptions lists tenant-owned subscriptions", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { app, service } = await bootHttp();
|
||||
service.subscribe("a", () => undefined, { tenantId: "acme" });
|
||||
service.subscribe("b", () => undefined, { tenantId: "acme" });
|
||||
service.subscribe("c", () => undefined, { tenantId: "other" });
|
||||
const res = await app.request("/event-bus/subscriptions");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { subscriptions: Array<{ topic: string }> };
|
||||
};
|
||||
const topics = body.data.subscriptions.map((s) => s.topic).sort();
|
||||
expect(topics).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("DELETE /event-bus/subscriptions/:id removes own subscription", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { app, service } = await bootHttp();
|
||||
const handle = service.subscribe("x", () => undefined, {
|
||||
tenantId: "acme",
|
||||
});
|
||||
const res = await app.request(`/event-bus/subscriptions/${handle.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { unsubscribed: boolean };
|
||||
};
|
||||
expect(body.data.unsubscribed).toBe(true);
|
||||
});
|
||||
|
||||
it("DELETE returns 404 for unknown id", { timeout: 30_000 }, async () => {
|
||||
const { app } = await bootHttp();
|
||||
const res = await app.request("/event-bus/subscriptions/missing", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("DELETE returns 404 for foreign-tenant id (no id leak)", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { app, service } = await bootHttp();
|
||||
// Create a subscription owned by 'other'.
|
||||
const handle = service.subscribe("x", () => undefined, {
|
||||
tenantId: "other",
|
||||
});
|
||||
const res = await app.request(`/event-bus/subscriptions/${handle.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /publish accepts idempotencyKey + timeoutMs + traceId in the body", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { app, service } = await bootHttp();
|
||||
let received: unknown = null;
|
||||
service.subscribe(
|
||||
"evt",
|
||||
(p) => {
|
||||
received = p;
|
||||
},
|
||||
{ tenantId: "acme" }
|
||||
);
|
||||
const res = await app.request("/event-bus/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
topic: "evt",
|
||||
payload: { id: "x" },
|
||||
idempotencyKey: "k1",
|
||||
timeoutMs: 2000,
|
||||
traceId: "trace-1",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBeLessThan(400);
|
||||
expect(received).toEqual({ id: "x" });
|
||||
});
|
||||
|
||||
it("POST /publish surfaces listener errors in the response", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { app, service } = await bootHttp();
|
||||
service.subscribe(
|
||||
"broken",
|
||||
() => {
|
||||
throw new Error("listener-blew-up");
|
||||
},
|
||||
{ tenantId: "acme" }
|
||||
);
|
||||
const res = await app.request("/event-bus/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ topic: "broken", payload: {} }),
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
data: { errors: Array<{ message: string }> };
|
||||
};
|
||||
expect(body.data.errors.length).toBe(1);
|
||||
expect(body.data.errors[0]?.message).toContain("listener-blew-up");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createEventBusPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { EventBusService } from "../../src/services/event-bus-service.types.js";
|
||||
import type { EventBusPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
async function bootPlugin() {
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: false,
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
const ctx = createPluginContext<EventBusPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as EventBusPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
return {
|
||||
app,
|
||||
service: services.get("eventBusService") as EventBusService,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tenant isolation — end-to-end against the real plugin lifecycle", () => {
|
||||
it("tenant A publish does not reach tenant B subscriber", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
const received: string[] = [];
|
||||
service.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push("B");
|
||||
},
|
||||
{ tenantId: "tenant-B" }
|
||||
);
|
||||
service.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push("A");
|
||||
},
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
const r = await service.publish(
|
||||
"order.created",
|
||||
{ id: "o1" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
expect(received).toEqual(["A"]);
|
||||
expect(r.delivered).toBe(1);
|
||||
expect(r.dropped).toBe(false);
|
||||
});
|
||||
|
||||
it("tenant B cannot unsubscribe tenant A's subscription", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
const handleA = service.subscribe("order.created", () => undefined, {
|
||||
tenantId: "tenant-A",
|
||||
});
|
||||
// tenant B attempt — throws TENANT_MISMATCH.
|
||||
expect(() => service.unsubscribe(handleA.id, "tenant-B")).toThrow();
|
||||
// tenant A's subscription still active.
|
||||
const received: number[] = [];
|
||||
service.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push(1);
|
||||
},
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
await service.publish("order.created", {}, { tenantId: "tenant-A" });
|
||||
expect(received.length).toBe(1);
|
||||
});
|
||||
|
||||
it("listSubscriptions filters by calling tenant", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
service.subscribe("a", () => undefined, { tenantId: "tenant-A" });
|
||||
service.subscribe("a", () => undefined, { tenantId: "tenant-A" });
|
||||
service.subscribe("a", () => undefined, { tenantId: "tenant-B" });
|
||||
const aList = service.listSubscriptions({ tenantId: "tenant-A" });
|
||||
expect(aList.length).toBe(2);
|
||||
expect(aList.every((s) => s.tenantId === "tenant-A")).toBe(true);
|
||||
});
|
||||
|
||||
it("idempotent subscription fires handler once for duplicate publish", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
const seen: number[] = [];
|
||||
service.subscribe(
|
||||
"order.completed",
|
||||
() => {
|
||||
seen.push(1);
|
||||
},
|
||||
{
|
||||
tenantId: "tenant-A",
|
||||
idempotencyKey: (p) => String((p as { id: string }).id),
|
||||
}
|
||||
);
|
||||
await service.publish(
|
||||
"order.completed",
|
||||
{ id: "o1" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
await service.publish(
|
||||
"order.completed",
|
||||
{ id: "o1" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
await service.publish(
|
||||
"order.completed",
|
||||
{ id: "o2" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
expect(seen.length).toBe(2);
|
||||
});
|
||||
|
||||
it("c.set('eventBus') handle round-trips publishes between handlers", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
// Register the tenant middleware BEFORE the plugin's
|
||||
// requestBus middleware so the latter observes the tenant
|
||||
// when it runs (Hono dispatches middleware in registration
|
||||
// order, so a tenant setter registered after the plugin
|
||||
// middleware would be invisible to it).
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("tenant" as never, { id: "tenant-A" } as never);
|
||||
await next();
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: false,
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
const ctx = createPluginContext<EventBusPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as EventBusPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
const service = services.get("eventBusService") as EventBusService;
|
||||
const received: unknown[] = [];
|
||||
service.subscribe(
|
||||
"ping",
|
||||
(p) => {
|
||||
received.push(p);
|
||||
},
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
app.get("/publish", async (c) => {
|
||||
const handle = c.get("eventBus");
|
||||
await handle.publish("ping", { hello: 1 });
|
||||
return c.text("ok");
|
||||
});
|
||||
const res = await app.request("/publish");
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toEqual([{ hello: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-event-bus.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-event-bus/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-event-bus/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/api-stack/api/api-plugin-event-bus/vite.config.ts
Normal file
37
packages/api-stack/api/api-plugin-event-bus/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-event-bus",
|
||||
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\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-event-bus/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-event-bus/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user