13 KiB
Add @suaveplan/api-plugin-event-bus — typed event-bus plugin wrapping @suaveplan/events on the @suaveplan/api foundation
Package: @suaveplan/api-plugin-event-bus (NEW, server-tier)
Status: PROPOSED
Type: new-package
- Spec delta: ./specs/api-plugin-event-bus/spec.md
Why
Genesis ships @suaveplan/events (typed event-bus core — pub/sub, async dispatch, interceptors, bounded-queue backpressure, idempotent listener wrapper) and @suaveplan/api (orchestrator) plus @suaveplan/api-plugin (definePlugin foundation). Multi-tenant apps composing @suaveplan/api repeatedly hand-roll the wiring that turns the raw EventBus into a per-tenant-scoped, request-aware service: construct the bus, register interceptors and the bounded queue, install middleware that scopes topics by c.get("tenant").id, expose a typed eventBusService slot for downstream plugins, and optionally publish/manage subscriptions over HTTP. Every consumer rewrites the same six steps and gets the per-tenant isolation slightly wrong each time.
This package wraps @suaveplan/events as a single definePlugin consumer so apps get a working, instrumented, per-tenant-scoped event bus by adding one entry to their plugins array. It is the second genesis-native plugin (after api-plugin-auth) that follows the canonical 3-way plug-and-play contract (Zod schemas at the boundary + OpenAPI route registration + canonical service-slot registration — no .surql DDL because the bus is in-memory).
Naming note.
enterprise/api-plugin-events.mddocuments a different package (social calendar / RSVP / meetup feed) that is deferred to a later wave. Thisevent-busplugin is intentionally singular in noun and scoped to the@suaveplan/eventslibrary wrapper.
What changes
Net-new package at 0.1.0. ADDED:
Plugin factory + descriptor surface
createEventBusPlugin(options: EventBusPluginOptions): PluginDescriptor<EventBusPluginConfig>— Zod-validated factory returning aPluginDescriptorfrom@suaveplan/api-plugin. The descriptor's lifecycle hooks implement the canonical 6-step onInit flow.EventBusPluginOptions(Zod-validated viaEventBusPluginOptionsSchema):{ interceptors?: readonly Interceptor[]; backpressure?: BackpressureOptions; idempotency?: IdempotencyOptions; routePrefix?: string (default "/event-bus"); mountRoutes?: boolean (default true); requireAuthForRoutes?: boolean (default true); tenantIsolation?: boolean (default true); maxTopicLength?: number (default 256); logger?: LoggerLike }.EventBusPluginConfig— the descriptor'sconfigSchema-inferred shape (resolved options).BackpressureOptions—{ capacity?: number (default 1024); policy?: QueuePolicy (default "drop-oldest") }.IdempotencyOptions—{ enabled?: boolean (default false); cacheCapacity?: number (default 4096) }.
Topic naming + per-tenant scoping
EVENT_TOPIC_PATTERN—/^[a-z0-9_\-:.]+$/regex; allpublish/subscribetopics MUST match.scopeTopic(topic, tenantId?)— returnstenant:<id>:<topic>whentenantIdis provided andtenantIsolationis enabled, else returnstopicunchanged. Pure function; exported so test code and downstream plugins can compute the canonical scoped form.unscopeTopic(scoped, tenantId?)— inverse ofscopeTopic; returns the original topic, ornullwhen the scoped string does not match the tenant prefix.
Service-registry slot — eventBusService
eventBusServiceregistered viactx.registerService("eventBusService", ...). Surface:publish(topic: string, payload: unknown, opts?: PublishOptions): Promise<PublishResult>subscribe(topic: string, handler: Listener<unknown>, opts?: SubscribeOptions): SubscriptionHandleunsubscribe(handleId: string): booleanlistSubscriptions(filter?: SubscriptionFilter): readonly SubscriptionRecord[]getRawBus(): EventBus<EventMap>— escape hatch for downstream consumers that need direct access (e.g.use(interceptor)at runtime).
SubscriptionHandle—{ id: string; topic: string; tenantId?: string; unsubscribe(): boolean }.PublishOptions—{ tenantId?: string; idempotencyKey?: string; traceId?: string; signal?: AbortSignal; timeoutMs?: number }.SubscribeOptions—{ tenantId?: string; idempotencyKey?: (payload: unknown) => string; once?: boolean }.PublishResult— re-export ofEmitResultfrom@suaveplan/events({ delivered: number; errors: readonly EventsError[] }) plus{ dropped: boolean }whose value reflects whether the backpressure queue overflowed.- ServiceRegistry module augmentation:
declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { eventBusService: EventBusService } }.
Middleware
requestBusMiddleware()— setsc.set("eventBus", scopedHandle)on every request.scopedHandleis a thin façade overeventBusServicethat auto-fillstenantIdfromc.get("tenant")?.id. HonoContextVariableMapaugmentation:interface ContextVariableMap { eventBus: ScopedEventBusHandle }.ScopedEventBusHandle— samepublish/subscribe/unsubscribe/listSubscriptionssurface asEventBusServiceminus the explicittenantIdparameter (it is bound from the request context).
Optional HTTP routes
Mounted under routePrefix (default /event-bus). Each route is gated by requireAuth() from @suaveplan/api-plugin-auth when requireAuthForRoutes: true (the default); otherwise they are public. When mountRoutes: false, no routes are mounted.
POST /event-bus/publish— body:{ topic, payload, idempotencyKey?, timeoutMs? }; response:{ delivered, dropped, errors: ErrorReportSchema[] }. Tenant is read fromc.get("tenant").GET /event-bus/subscriptions— response:{ subscriptions: SubscriptionRecordSchema[] }. Tenant-scoped — only subscriptions owned by the current tenant are returned.DELETE /event-bus/subscriptions/:id— params:{ id }; response:{ unsubscribed: boolean }. Returns 404 when the id is unknown to the current tenant (404 to avoid leaking cross-tenant ids).
Backpressure + idempotency wiring
BoundedQueuefrom@suaveplan/eventswraps everypublishso a slow listener cannot block faster ones. When the queue overflows underdrop-oldest/drop-newest, the returnedPublishResult.droppedistrueand theevent_bus.dropped_total{policy}counter increments by 1. Underpolicy: "throw"the publish promise rejects withEventBusError(EVENT_BUS.BACKPRESSURE_DROPPED).- The
idempotentwrapper from@suaveplan/eventsis composed at subscribe time whenopts.idempotencyKey(or the plugin-levelidempotency.enabled) is configured. The keyOf function defaults to(envelope) => envelope.idempotencyKey ?? envelope.id; duplicates are silently dropped and counted viaevent_bus.duplicates_total.
Errors + observability
EventBusError extends SuaveplanError<EventBusErrorCode>viadefineCodes("EVENT_BUS", [...]). Codes:INVALID_INPUT— Zod parse ofEventBusPluginOptionsor a route body failed.INVALID_TOPIC— topic failedEVENT_TOPIC_PATTERNor exceededmaxTopicLength.SERVICE_NOT_AVAILABLE— a requiredServiceRegistryslot was absent at init.ROUTE_PREFIX_COLLISION—routePrefixcollides with previously registered routes.SUBSCRIPTION_NOT_FOUND—unsubscribe(id)called with an unknown id.BACKPRESSURE_DROPPED— queue overflowed underpolicy: "throw".TENANT_MISMATCH— handler tried to operate on another tenant's subscription id.PUBLISH_FAILED— underlyingEventBus.emitAsyncrejected (wraps theEventsErrorascause).
- OTel: counters
event_bus.published_total{outcome,topic_root},event_bus.dropped_total{policy},event_bus.duplicates_total,event_bus.subscriptions_active, histogramevent_bus.publish_duration_ms.
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.
Out of scope
- Cross-process / cross-node delivery. This plugin wraps
@suaveplan/eventswhich is intentionally in-process. A futureapi-plugin-event-bus-redis(or similar transport) would extend it. - Persistence. Subscriptions are in-memory and per-process — restarting the API server clears them. A persistent registry belongs in a sibling plugin.
- WebSocket / SSE fan-out. Operators wanting a push surface compose
api-plugin-sseseparately and calleventBusService.subscribefrom inside that plugin. - Per-event RBAC beyond the
requireAuthroute gate. Fine-grained authorisation (e.g. "tenant admins can publish, tenant members cannot") composesrequireRoleat the application layer. - Schema validation of event payloads. Payloads are
unknownat the bus boundary — callers Zod-validate at the producer/consumer if they want a contract. - Dead-letter queue. Listener errors are surfaced via
PublishResult.errorsand counted; a DLQ persistence plugin would be a follow-up.
Source mapping
This package is genesis-native — there is NO upstream enterprise/api-plugin-event-bus.md (the unrelated enterprise/api-plugin-events.md documents a social-calendar package shipping in a later wave). Design intent draws from:
- The shipped
@suaveplan/eventslibrary (EventBus,BoundedQueue,idempotent,composeinterceptor chain). - The shipped
@suaveplan/api-pluginPluginContext+ServiceRegistryshape. - The shipped
@suaveplan/api-openapidefinePluginRoutes+RouteBuilderpattern. - The shipped
@suaveplan/api-plugin-authreference implementation of the 6-steponInitlifecycle (verbatim layout for tests, dirs, and the spec-conformance file). - The shipped
@suaveplan/api-tenantc.get("tenant")augmentation.
Requirements with no source line
| Requirement | Justification |
|---|---|
| createEventBusPlugin factory | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| 6-step onInit lifecycle | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Per-tenant topic scoping | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Tenant isolation enforcement | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| eventBusService registry slot | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| ScopedEventBusHandle on c.get("eventBus") | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Topic name validation | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Bounded-queue backpressure wiring | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Idempotency wrapper composition | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Optional HTTP route surface (publish / list / delete) | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Zod boundary validation for routes + options | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| OTel instruments (5 metrics) | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| 8-code EventBusError registry | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
| Subpath exports per feature | Genesis-native event-bus integration plugin — no upstream parity; design driven by @suaveplan/events library contract |
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/api-plugin-event-bus to enumerate. As of authoring time the package is net-new with no dependents.
No dependents (verified by bunx turbo ls --filter=...@suaveplan/api-plugin-event-bus returning only the package itself).
Catalog additions required
None — every catalog key used (catalog:server, catalog:observability, catalog:, catalog:forms, catalog:build, catalog:testing) already exists in the root package.json.