Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { createEventBusPlugin } from "./plugin.js";

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,313 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
import { resetEventBusInstruments } from "../otel/instruments.js";
import type { EventBusService } from "../services/event-bus-service.types.js";
import type { EventBusPluginConfig } from "../types/types.js";
import { createEventBusPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeContext(
services: Map<string, unknown>,
defaults: EventBusPluginConfig
) {
return createPluginContext<EventBusPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: defaults,
services,
});
}
describe("createEventBusPlugin", () => {
it("createEventBusPlugin resolves defaults and returns a typed PluginDescriptor", {
timeout: 5000,
}, () => {
const plugin = createEventBusPlugin({});
expect(plugin.name).toBe("event-bus");
expect(plugin.version).toBe("0.1.0");
const defaults = plugin.defaults as EventBusPluginConfig;
expect(defaults.routePrefix).toBe("/event-bus");
expect(defaults.mountRoutes).toBe(true);
expect(defaults.requireAuthForRoutes).toBe(true);
expect(defaults.tenantIsolation).toBe(true);
expect(defaults.backpressure.policy).toBe("drop-oldest");
expect(defaults.backpressure.capacity).toBe(1024);
expect(defaults.idempotency.enabled).toBe(false);
expect(defaults.maxTopicLength).toBe(256);
});
it("createEventBusPlugin throws EVENT_BUS.INVALID_INPUT for invalid options", {
timeout: 5000,
}, () => {
try {
createEventBusPlugin({
routePrefix: "no-leading-slash",
} as never);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EventBusError);
expect((err as EventBusError).eventBusCode).toBe(
EVENT_BUS_CODES.INVALID_INPUT
);
}
});
});
describe("createEventBusPlugin.onInit", () => {
it("onInit throws SERVICE_NOT_AVAILABLE when the app slot is missing", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const plugin = createEventBusPlugin({ mountRoutes: false });
const ctx = makeContext(
new Map<string, unknown>(),
plugin.defaults as EventBusPluginConfig
);
try {
await plugin.onInit?.(ctx);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EventBusError);
expect((err as EventBusError).eventBusCode).toBe(
EVENT_BUS_CODES.SERVICE_NOT_AVAILABLE
);
}
});
it("onInit registers eventBusService in the ServiceRegistry", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const plugin = createEventBusPlugin({ mountRoutes: false });
const ctx = makeContext(
services,
plugin.defaults as EventBusPluginConfig
);
await plugin.onInit?.(ctx);
const svc = services.get("eventBusService") as EventBusService;
expect(svc).toBeDefined();
expect(typeof svc.publish).toBe("function");
expect(typeof svc.subscribe).toBe("function");
});
it("onInit installs request-bus middleware that exposes ScopedEventBusHandle", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
const plugin = createEventBusPlugin({
mountRoutes: false,
requireAuthForRoutes: false,
});
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
let observedTenant: string | null | undefined;
app.get("/probe", (c) => {
const handle = c.get("eventBus");
observedTenant = handle?.tenantId ?? null;
return c.text("ok");
});
const res = await app.request("/probe");
expect(res.status).toBe(200);
expect(observedTenant).toBeNull();
});
it("onInit mounts routes onto OpenAPIRegistry when present", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const plugin = createEventBusPlugin({
requireAuthForRoutes: false,
});
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
expect(registry.getRoutesByPlugin("event-bus")).toHaveLength(3);
});
it("mountRoutes=false skips route registration", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const plugin = createEventBusPlugin({
mountRoutes: false,
requireAuthForRoutes: false,
});
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
expect(registry.getRoutesByPlugin("event-bus")).toHaveLength(0);
});
it("onInit throws ROUTE_PREFIX_COLLISION when routes already occupy the prefix", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
registry.registerRoute({
method: "get",
path: "/event-bus/x",
tags: [],
pluginName: "other",
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const plugin = createEventBusPlugin({
requireAuthForRoutes: false,
});
try {
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EventBusError);
expect((err as EventBusError).eventBusCode).toBe(
EVENT_BUS_CODES.ROUTE_PREFIX_COLLISION
);
}
});
it("mounts onto Hono only when openAPIRegistry is absent", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
const plugin = createEventBusPlugin({
requireAuthForRoutes: false,
});
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
// The publish route should now answer on /event-bus/publish.
const res = await app.request("/event-bus/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic: "order.created", payload: {} }),
});
// Should be 2xx (handler ran).
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(400);
});
it("onClose clears the cached service", { timeout: 30_000 }, async () => {
resetEventBusInstruments();
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const plugin = createEventBusPlugin({ mountRoutes: false });
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
await plugin.onClose?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
// The state-internal cleared field is private; we observe via
// the lack of throw and the descriptor's metadata.
expect(plugin.name).toBe("event-bus");
});
it("mounts under custom routePrefix '/'", { timeout: 30_000 }, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
const plugin = createEventBusPlugin({
mountRoutes: true,
requireAuthForRoutes: false,
routePrefix: "/",
});
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
// Publish reachable at top-level /publish.
const res = await app.request("/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic: "x", payload: {} }),
});
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(400);
});
it("requireAuthForRoutes=true installs requireAuth() under the prefix", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const plugin = createEventBusPlugin({
mountRoutes: true,
requireAuthForRoutes: true,
});
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
// Anonymous request — requireAuth() raises AuthError because
// `c.get("user")` is null. The default Hono error handler maps
// that to 500 (the api error-handler middleware would map to
// 401, but the bare app does not have it wired). Either way the
// request did not succeed — the gate fired.
const res = await app.request("/event-bus/subscriptions");
expect(res.status).not.toBe(200);
});
it("requireAuthForRoutes=true with root prefix mounts /* gate", {
timeout: 30_000,
}, async () => {
resetEventBusInstruments();
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
const plugin = createEventBusPlugin({
mountRoutes: true,
requireAuthForRoutes: true,
routePrefix: "/",
});
await plugin.onInit?.(
makeContext(services, plugin.defaults as EventBusPluginConfig)
);
const res = await app.request("/subscriptions");
expect(res.status).not.toBe(200);
});
});

View File

@@ -0,0 +1,204 @@
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import { definePluginRoutes } from "@suaveplan/api-openapi";
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import { requireAuth } from "@suaveplan/api-plugin-auth";
import { Hono } from "hono";
import { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js";
import { requestBusMiddleware } from "../middleware/request-bus.js";
import { buildRoutes } from "../routes/build-routes.js";
import { EventBusPluginOptionsSchema } from "../schemas/options.js";
import { createEventBusService } from "../services/event-bus-service.js";
import type { EventBusService } from "../services/event-bus-service.types.js";
import "../services/registry-augmentation.js";
import type {
EventBusPluginConfig,
EventBusPluginOptions,
} from "../types/types.js";
interface PluginState {
service: EventBusService | null;
}
/**
* Factory — produces a typed `PluginDescriptor` for the event-bus
* plugin.
*
* Zod-validates the supplied options at the boundary so invalid input
* fails fast with `EventBusError(EVENT_BUS.INVALID_INPUT)` carrying
* the `ZodError` as `cause`. The returned descriptor's `onInit`
* executes the canonical 6-step lifecycle:
*
* 1. Resolve `app` + (optional) `openAPIRegistry` from the host
* `ServiceRegistry`.
* 2. Construct an `EventBus` from `@suaveplan/events` and apply
* interceptors / back-pressure / idempotency.
* 3. Register `eventBusService` in the `ServiceRegistry`.
* 4. Per-tenant scoping is built into the service via
* `tenantIsolation`.
* 5. Install the per-request middleware that puts the
* `ScopedEventBusHandle` on `c.set("eventBus", ...)`.
* 6. Optionally mount the 3 HTTP routes under `routePrefix`,
* gated by `requireAuth()` when `requireAuthForRoutes !== false`.
*/
export function createEventBusPlugin(
options: EventBusPluginOptions
): PluginDescriptor<EventBusPluginConfig> {
const parsed = EventBusPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new EventBusError(
EVENT_BUS_CODES.INVALID_INPUT,
"createEventBusPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const resolved = parsed.data as EventBusPluginConfig;
const state: PluginState = { service: null };
return definePlugin<EventBusPluginConfig>({
name: "event-bus",
version: "0.1.0",
description:
"Typed event-bus plugin for @suaveplan/api. Wraps @suaveplan/events with per-tenant topic scoping, backpressure, idempotent subscriptions, and 3 optional auth-gated OpenAPI routes.",
defaults: resolved,
tags: ["events", "infrastructure"],
onInit: async (ctx) => runOnInit(ctx, resolved, state),
onClose: async () => {
state.service = null;
},
});
}
async function runOnInit(
ctx: PluginContext<EventBusPluginConfig>,
config: EventBusPluginConfig,
state: PluginState
): Promise<void> {
// (1) Resolve required services from the host ServiceRegistry.
const app = ctx.getService("app");
if (app === undefined) {
throw new EventBusError(
EVENT_BUS_CODES.SERVICE_NOT_AVAILABLE,
"event-bus plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const openAPIRegistry = ctx.getService("openAPIRegistry");
// (2) Construct the event-bus service with the configured
// interceptors / backpressure / idempotency.
const service = createEventBusService({
interceptors: config.interceptors,
backpressure: config.backpressure,
idempotency: config.idempotency,
tenantIsolation: config.tenantIsolation,
maxTopicLength: config.maxTopicLength,
});
state.service = service;
// (3) Register the eventBusService slot.
ctx.registerService("eventBusService", service);
// (4) Per-tenant scoping is baked into the service when
// `tenantIsolation` is true — no additional wiring needed.
// (5) Install the per-request middleware that surfaces the
// `ScopedEventBusHandle` on `c.set("eventBus", ...)`.
app.use(
"*",
requestBusMiddleware({
service,
tenantIsolation: config.tenantIsolation,
})
);
// (6) Optionally mount the HTTP routes.
if (config.mountRoutes) {
mountRoutes({
app,
openAPIRegistry,
service,
tenantIsolation: config.tenantIsolation,
requireAuthForRoutes: config.requireAuthForRoutes,
routePrefix: config.routePrefix,
});
}
}
interface MountRoutesArgs {
readonly app: OpenAPIHono;
readonly openAPIRegistry: OpenAPIRegistry | undefined;
readonly service: EventBusService;
readonly tenantIsolation: boolean;
readonly requireAuthForRoutes: boolean;
readonly routePrefix: string;
}
function mountRoutes(args: MountRoutesArgs): void {
const {
app,
openAPIRegistry,
service,
tenantIsolation,
requireAuthForRoutes,
routePrefix,
} = args;
const entries = buildRoutes({ service, tenantIsolation });
const prefixedEntries = entries.map((entry) => ({
...entry,
route: {
...entry.route,
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
},
}));
if (requireAuthForRoutes) {
const gateMount = routePrefix === "/" ? "/*" : `${routePrefix}/*`;
app.use(gateMount, requireAuth());
}
if (openAPIRegistry !== undefined) {
const existing = openAPIRegistry
.getRoutes()
.filter((r) => r.path.startsWith(routePrefix));
if (existing.length > 0) {
throw new EventBusError(
EVENT_BUS_CODES.ROUTE_PREFIX_COLLISION,
`Route prefix '${routePrefix}' collides with previously registered routes`,
{
context: {
prefix: routePrefix,
existing: existing.map((r) => r.path),
statusCode: 500,
},
}
);
}
definePluginRoutes(prefixedEntries, {
app,
registry: openAPIRegistry,
pluginName: "event-bus",
});
return;
}
// No OpenAPI registry — mount onto Hono only.
const sub = new Hono() as OpenAPIHono;
for (const entry of entries) {
const method = entry.route.method as
| "get"
| "post"
| "put"
| "patch"
| "delete";
sub[method](
entry.route.path,
async (c) => (await entry.handler(c as never)) as Response
);
}
app.route(routePrefix === "/" ? "/" : routePrefix, sub);
}