first commit
This commit is contained in:
196
docs/api-stack/api/api-plugin-event-bus/README.md
Normal file
196
docs/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).
|
||||
|
||||
@@ -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,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,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.
|
||||
100
docs/api-stack/api/api-plugin-event-bus/src/otel/instruments.md
Normal file
100
docs/api-stack/api/api-plugin-event-bus/src/otel/instruments.md
Normal 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`.
|
||||
120
docs/api-stack/api/api-plugin-event-bus/src/plugin/plugin.md
Normal file
120
docs/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,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.
|
||||
106
docs/api-stack/api/api-plugin-event-bus/src/schemas/options.md
Normal file
106
docs/api-stack/api/api-plugin-event-bus/src/schemas/options.md
Normal 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.
|
||||
@@ -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,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,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).
|
||||
110
docs/api-stack/api/api-plugin-event-bus/src/services/helpers.md
Normal file
110
docs/api-stack/api/api-plugin-event-bus/src/services/helpers.md
Normal 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`.
|
||||
@@ -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,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.
|
||||
119
docs/api-stack/api/api-plugin-event-bus/src/topic/topic.md
Normal file
119
docs/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.
|
||||
91
docs/api-stack/api/api-plugin-event-bus/src/types/types.md
Normal file
91
docs/api-stack/api/api-plugin-event-bus/src/types/types.md
Normal 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.
|
||||
Reference in New Issue
Block a user