Initial Commit
This commit is contained in:
81
packages/api-stack/api/api-ws/src/broker/broker.md
Normal file
81
packages/api-stack/api/api-ws/src/broker/broker.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# `broker` — multi-host fan-out contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A single-host deployment never needs a broker — the `SubscriptionBus`
|
||||
inside that process already sees every connection. Production deployments
|
||||
that run multiple host replicas behind a load balancer DO need one: a
|
||||
broadcast issued on host A has to reach the connections attached to host
|
||||
B without migrating connections between processes. The `WsBroker` contract
|
||||
defined here is the seam those broker adapters implement.
|
||||
|
||||
Concrete brokers (`api-ws-broker-in-memory` for single-host tests,
|
||||
`api-ws-broker-redis` for production) plug behind this interface and stay
|
||||
out of plugin code paths.
|
||||
|
||||
## Features
|
||||
|
||||
- `BrokerEnvelope` — wire payload (`topic`, `tenant`, `payload`, optional
|
||||
`originHost`).
|
||||
- `BrokerHandler` — subscriber callback signature.
|
||||
- `BrokerSubscription` — disposal handle returned from `subscribe`.
|
||||
- `WsBroker<Payload>` interface — `publish`, `subscribe`, `dispose`. Each
|
||||
method may return synchronously or asynchronously so in-memory brokers
|
||||
stay cheap and Redis-backed brokers can `await` round trips.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { WsBroker } from "@suaveplan/api-ws/broker";
|
||||
|
||||
async function fanOut(broker: WsBroker<{ orderId: string }>): Promise<void> {
|
||||
await broker.publish("orders.created", {
|
||||
topic: "orders.created",
|
||||
tenant: tenantId,
|
||||
payload: { orderId: "o1" },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { WsBroker, BrokerSubscription } from "@suaveplan/api-ws/broker";
|
||||
|
||||
async function bridge(
|
||||
broker: WsBroker<unknown>,
|
||||
local: { broadcast: (...args: unknown[]) => void },
|
||||
): Promise<BrokerSubscription> {
|
||||
const subscription = await broker.subscribe("orders.created", (envelope) => {
|
||||
local.broadcast(envelope.topic, {
|
||||
tenant: envelope.tenant,
|
||||
payload: envelope.payload,
|
||||
});
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BrokerEnvelope<Payload> { topic, tenant, payload, originHost? }`.
|
||||
- `type BrokerHandler<Payload> = (env: BrokerEnvelope<Payload>) => void | Promise<void>`.
|
||||
- `interface BrokerSubscription { topic; unsubscribe(): void | Promise<void> }`.
|
||||
- `interface WsBroker<Payload> { publish, subscribe, dispose }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The contract intentionally does not specify reliability semantics. Each
|
||||
adapter declares its own delivery guarantees (`in-memory` is at-most-
|
||||
once with FIFO inside the process; `redis` is at-most-once with Redis
|
||||
pub/sub semantics). Plugins that need stronger guarantees use the
|
||||
saga / 2PC capabilities elsewhere in the foundation.
|
||||
- `originHost` is optional so adapters that don't need loop-back
|
||||
suppression can omit it. Adapters that DO suppress loop-back compare
|
||||
against the local host's identity at the receive side.
|
||||
- `dispose` MUST close every subscription cleanly; the conformance
|
||||
harness pins this invariant against every concrete adapter.
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
BrokerEnvelope,
|
||||
BrokerHandler,
|
||||
BrokerSubscription,
|
||||
WsBroker,
|
||||
} from "./broker.js";
|
||||
|
||||
describe("WsBroker conformance", () => {
|
||||
it("declares publish, subscribe, and dispose", { timeout: 5000 }, () => {
|
||||
expectTypeOf<WsBroker>().toHaveProperty("publish");
|
||||
expectTypeOf<WsBroker>().toHaveProperty("subscribe");
|
||||
expectTypeOf<WsBroker>().toHaveProperty("dispose");
|
||||
});
|
||||
|
||||
it("publish accepts a BrokerEnvelope", { timeout: 5000 }, () => {
|
||||
expectTypeOf<WsBroker["publish"]>().parameters.toEqualTypeOf<
|
||||
[topic: string, envelope: BrokerEnvelope<unknown>]
|
||||
>();
|
||||
});
|
||||
|
||||
it("subscribe returns a BrokerSubscription (sync or async)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<WsBroker["subscribe"]>().returns.toEqualTypeOf<
|
||||
BrokerSubscription | Promise<BrokerSubscription>
|
||||
>();
|
||||
});
|
||||
|
||||
it("BrokerHandler is a function of BrokerEnvelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<BrokerHandler<number>>().toEqualTypeOf<
|
||||
(envelope: BrokerEnvelope<number>) => void | Promise<void>
|
||||
>();
|
||||
});
|
||||
});
|
||||
47
packages/api-stack/api/api-ws/src/broker/broker.ts
Normal file
47
packages/api-stack/api/api-ws/src/broker/broker.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
|
||||
/**
|
||||
* Wire payload published across host replicas. Carries everything the
|
||||
* receiving host needs to redeliver the broadcast to its locally-attached
|
||||
* subscribers — connections are NOT migrated, only the broadcast event is.
|
||||
*/
|
||||
export interface BrokerEnvelope<Payload = unknown> {
|
||||
readonly topic: string;
|
||||
readonly tenant: TenantId;
|
||||
readonly payload: Payload;
|
||||
readonly originHost?: string;
|
||||
}
|
||||
|
||||
/** Subscriber handler invoked once per broker-delivered envelope. */
|
||||
export type BrokerHandler<Payload = unknown> = (
|
||||
envelope: BrokerEnvelope<Payload>
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** Disposal handle returned by {@link WsBroker.subscribe}. */
|
||||
export interface BrokerSubscription {
|
||||
readonly topic: string;
|
||||
unsubscribe(): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-host fan-out contract.
|
||||
*
|
||||
* Brokers wrap a host's local `SubscriptionBus` so that
|
||||
* `bus.broadcast(...)` is visible to every other host replica in the
|
||||
* deployment. The broker MUST NOT migrate connections between hosts; it
|
||||
* only makes broadcast events visible.
|
||||
*
|
||||
* Concrete implementations live in `api-ws-broker-in-memory` (single-host
|
||||
* default) and `api-ws-broker-redis` (multi-host production).
|
||||
*/
|
||||
export interface WsBroker<Payload = unknown> {
|
||||
publish(
|
||||
topic: string,
|
||||
envelope: BrokerEnvelope<Payload>
|
||||
): void | Promise<void>;
|
||||
subscribe(
|
||||
topic: string,
|
||||
handler: BrokerHandler<Payload>
|
||||
): BrokerSubscription | Promise<BrokerSubscription>;
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
6
packages/api-stack/api/api-ws/src/broker/index.ts
Normal file
6
packages/api-stack/api/api-ws/src/broker/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
BrokerEnvelope,
|
||||
BrokerHandler,
|
||||
BrokerSubscription,
|
||||
WsBroker,
|
||||
} from "./broker.js";
|
||||
Reference in New Issue
Block a user