first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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`.

View File

@@ -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).

View File

@@ -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.

View 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.

View File

@@ -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.