Initial Commit
This commit is contained in:
327
packages/api-stack/api/api-ws-broker-redis/src/broker/broker.ts
Normal file
327
packages/api-stack/api/api-ws-broker-redis/src/broker/broker.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type {
|
||||
BrokerEnvelope,
|
||||
BrokerHandler,
|
||||
BrokerSubscription,
|
||||
WsBroker,
|
||||
} from "@suaveplan/api-ws/broker";
|
||||
import {
|
||||
WS_BROKER_REDIS_CODES,
|
||||
WsBrokerRedisError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type BufferedEvent,
|
||||
readBufferedEvents,
|
||||
writeBufferedEvent,
|
||||
} from "../event-buffer/event-buffer.js";
|
||||
import {
|
||||
type BrokerHealthSnapshot,
|
||||
evaluateBrokerHealth,
|
||||
} from "../health/health.js";
|
||||
import {
|
||||
connectionLostCounter,
|
||||
publishDurationHistogram,
|
||||
publishesCounter,
|
||||
} from "../instruments/instruments.js";
|
||||
import {
|
||||
type RedisWsBrokerOptions,
|
||||
type ResolvedRedisWsBrokerOptions,
|
||||
redisWsBrokerOptionsSchema,
|
||||
resolveRedisWsBrokerOptions,
|
||||
} from "../options/options.js";
|
||||
import { encodeEnvelope } from "../publish/publish.js";
|
||||
import {
|
||||
createRespClient,
|
||||
type RedisPubSubLike,
|
||||
} from "../resp-client/resp-client.js";
|
||||
import {
|
||||
channelFromTopic,
|
||||
dispatchInboundFrame,
|
||||
LocalSubscriberRegistry,
|
||||
topicFromChannel,
|
||||
} from "../subscribe/subscribe.js";
|
||||
|
||||
/**
|
||||
* Concrete shape returned by {@link createRedisWsBroker}. Extends the
|
||||
* abstract `WsBroker` contract with broker-specific introspection used
|
||||
* by the host's readiness endpoint and by tests.
|
||||
*/
|
||||
export interface RedisWsBroker<Payload = unknown> extends WsBroker<Payload> {
|
||||
publish(topic: string, envelope: BrokerEnvelope<Payload>): Promise<void>;
|
||||
subscribe(
|
||||
topic: string,
|
||||
handler: BrokerHandler<Payload>
|
||||
): Promise<BrokerSubscription>;
|
||||
dispose(): Promise<void>;
|
||||
/** Append-to-buffer surface consumed by `api-ws-bun` + `api-sse-bun`. */
|
||||
writeBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
event: BufferedEvent
|
||||
): Promise<void>;
|
||||
/** Replay-from-buffer surface consumed by `api-ws-bun` + `api-sse-bun`. */
|
||||
readBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
sinceEventId: number
|
||||
): Promise<readonly BufferedEvent[]>;
|
||||
/** Health probe surface consumed by `host.readiness()`. */
|
||||
healthz(): BrokerHealthSnapshot;
|
||||
/** Disposed flag — set after `dispose()` resolves. */
|
||||
isDisposed(): boolean;
|
||||
/** Resolved (defaulted) options the broker is using. */
|
||||
readonly options: ResolvedRedisWsBrokerOptions;
|
||||
}
|
||||
|
||||
/** Factory contract for swapping in an alternative client (e.g. ioredis adapter). */
|
||||
export type RedisClientFactory = (
|
||||
url: string,
|
||||
role: "publisher" | "subscriber"
|
||||
) => RedisPubSubLike;
|
||||
|
||||
/** Extended options accepted by {@link createRedisWsBroker} (vs. the wire schema). */
|
||||
export interface CreateRedisWsBrokerOptions extends RedisWsBrokerOptions {
|
||||
/** Injectable client factory; defaults to the built-in RESP client. */
|
||||
readonly clientFactory?: RedisClientFactory;
|
||||
/** Wait-for-ready timeout when constructing the broker. Default 5_000 ms. */
|
||||
readonly readyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 5_000;
|
||||
|
||||
function waitForReady(
|
||||
client: RedisPubSubLike,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
if (client.isReady) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off();
|
||||
reject(
|
||||
new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONNECTION_LOST,
|
||||
`Redis client did not become ready within ${timeoutMs}ms`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
const off = client.on((evt) => {
|
||||
if (evt.event === "ready") {
|
||||
clearTimeout(timer);
|
||||
off();
|
||||
resolve();
|
||||
} else if (evt.event === "error" || evt.event === "close") {
|
||||
clearTimeout(timer);
|
||||
off();
|
||||
reject(
|
||||
new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONNECTION_LOST,
|
||||
`Redis connect failed: ${evt.event === "error" ? evt.error.message : "closed"}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Redis-backed `WsBroker`. Two RESP clients are opened against the
|
||||
* supplied URL — one for replies (publish/buffer ops) and one held in
|
||||
* subscribe-mode for the inbound pub/sub stream. Pattern-subscription is
|
||||
* established eagerly at construction time so the first publish is visible
|
||||
* to peers without further setup.
|
||||
*
|
||||
* Lifecycle:
|
||||
*
|
||||
* 1. `createRedisWsBroker(opts)` parses + resolves options, opens both
|
||||
* clients, awaits ready on each, then `PSUBSCRIBE`s on
|
||||
* `<channelPrefix>*`.
|
||||
* 2. `broker.broadcast` and `broker.publish` (alias) encode the envelope
|
||||
* and `PUBLISH` to `<channelPrefix><topic>`.
|
||||
* 3. The subscribe loop decodes each frame and routes via
|
||||
* {@link LocalSubscriberRegistry}.
|
||||
* 4. `broker.dispose()` `PUNSUBSCRIBE`s, quits both clients, and refuses
|
||||
* subsequent calls.
|
||||
*/
|
||||
export async function createRedisWsBroker<Payload = unknown>(
|
||||
options: CreateRedisWsBrokerOptions
|
||||
): Promise<RedisWsBroker<Payload>> {
|
||||
// Strip broker-factory-only fields before schema validation; the schema
|
||||
// describes the wire shape (RedisWsBrokerOptions), not the extra
|
||||
// factory-only knobs (clientFactory, readyTimeoutMs).
|
||||
const { clientFactory: _cf, readyTimeoutMs: _rt, ...schemaInput } = options;
|
||||
const parsed = redisWsBrokerOptionsSchema.safeParse(schemaInput);
|
||||
if (!parsed.success) {
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONFIG_INVALID,
|
||||
`createRedisWsBroker received invalid options: ${parsed.error.message}`
|
||||
);
|
||||
}
|
||||
const resolved = resolveRedisWsBrokerOptions(
|
||||
parsed.data as RedisWsBrokerOptions
|
||||
);
|
||||
const factory: RedisClientFactory =
|
||||
options.clientFactory ??
|
||||
((url, role) =>
|
||||
createRespClient({
|
||||
url,
|
||||
commandTimeoutMs: resolved.commandTimeoutMs,
|
||||
isSubscriber: role === "subscriber",
|
||||
}));
|
||||
const publisher = factory(resolved.connection, "publisher");
|
||||
const subscriber = factory(resolved.connection, "subscriber");
|
||||
const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
await Promise.all([
|
||||
waitForReady(publisher, readyTimeout),
|
||||
waitForReady(subscriber, readyTimeout),
|
||||
]);
|
||||
|
||||
const registry = new LocalSubscriberRegistry<Payload>();
|
||||
let disposed = false;
|
||||
let lastError: string | undefined;
|
||||
const pattern = `${resolved.channelPrefix}*`;
|
||||
|
||||
const detachSub = subscriber.on((evt) => {
|
||||
if (evt.event === "message") {
|
||||
const topic = topicFromChannel(evt.channel, resolved.channelPrefix);
|
||||
if (topic === null) return;
|
||||
void dispatchInboundFrame(registry, topic, evt.payload, (err) => {
|
||||
lastError = err.message;
|
||||
});
|
||||
} else if (evt.event === "close" || evt.event === "error") {
|
||||
connectionLostCounter().add(1);
|
||||
if (evt.event === "error") lastError = evt.error.message;
|
||||
}
|
||||
});
|
||||
const detachPub = publisher.on((evt) => {
|
||||
if (evt.event === "close" || evt.event === "error") {
|
||||
connectionLostCounter().add(1);
|
||||
if (evt.event === "error") lastError = evt.error.message;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await subscriber.psubscribe(pattern);
|
||||
} catch (err) {
|
||||
await publisher.quit().catch(() => undefined);
|
||||
await subscriber.quit().catch(() => undefined);
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.SUBSCRIBE_FAILED,
|
||||
`PSUBSCRIBE failed: ${(err as Error).message}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
function assertLive(operation: string): void {
|
||||
if (disposed) {
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONNECTION_LOST,
|
||||
`cannot ${operation}: broker has been disposed`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishImpl(
|
||||
topic: string,
|
||||
envelope: BrokerEnvelope<Payload>
|
||||
): Promise<void> {
|
||||
assertLive("publish");
|
||||
const start = Date.now();
|
||||
const stamped: BrokerEnvelope<Payload> = {
|
||||
...envelope,
|
||||
originHost: envelope.originHost ?? resolved.hostId,
|
||||
};
|
||||
let frame: ReturnType<typeof encodeEnvelope<Payload>>;
|
||||
try {
|
||||
frame = encodeEnvelope(stamped, {
|
||||
compressionThresholdBytes: resolved.compressionThresholdBytes,
|
||||
disableCompression: resolved.disableCompression,
|
||||
});
|
||||
} catch (err) {
|
||||
publishesCounter().add(1, { topic, outcome: "error" });
|
||||
throw err;
|
||||
}
|
||||
const channel = channelFromTopic(topic, resolved.channelPrefix);
|
||||
try {
|
||||
await publisher.publish(channel, frame.bytes);
|
||||
publishesCounter().add(1, { topic, outcome: "ok" });
|
||||
publishDurationHistogram().record(Date.now() - start);
|
||||
} catch (err) {
|
||||
publishesCounter().add(1, { topic, outcome: "error" });
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.PUBLISH_FAILED,
|
||||
`PUBLISH failed for topic=${topic}: ${(err as Error).message}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const broker: RedisWsBroker<Payload> = {
|
||||
options: resolved,
|
||||
async publish(topic, envelope): Promise<void> {
|
||||
await publishImpl(topic, envelope);
|
||||
},
|
||||
async subscribe(
|
||||
topic: string,
|
||||
handler: BrokerHandler<Payload>
|
||||
): Promise<BrokerSubscription> {
|
||||
assertLive("subscribe");
|
||||
registry.add(topic, handler);
|
||||
let active = true;
|
||||
return {
|
||||
topic,
|
||||
unsubscribe(): void {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
registry.remove(topic, handler);
|
||||
},
|
||||
};
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
registry.clear();
|
||||
detachSub();
|
||||
detachPub();
|
||||
try {
|
||||
await subscriber.punsubscribe(pattern);
|
||||
} catch {
|
||||
/* socket may have already closed; safe to ignore */
|
||||
}
|
||||
await Promise.all([
|
||||
publisher.quit().catch(() => undefined),
|
||||
subscriber.quit().catch(() => undefined),
|
||||
]);
|
||||
},
|
||||
async writeBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
event: BufferedEvent
|
||||
): Promise<void> {
|
||||
assertLive("writeBuffer");
|
||||
await writeBufferedEvent(publisher, tenant, topic, event, {
|
||||
keyPrefix: resolved.keyPrefix,
|
||||
maxEvents: resolved.eventBufferMaxEvents,
|
||||
ttlSec: resolved.eventBufferTtlSec,
|
||||
});
|
||||
},
|
||||
async readBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
sinceEventId: number
|
||||
): Promise<readonly BufferedEvent[]> {
|
||||
assertLive("readBuffer");
|
||||
return readBufferedEvents(publisher, tenant, topic, sinceEventId, {
|
||||
keyPrefix: resolved.keyPrefix,
|
||||
maxEvents: resolved.eventBufferMaxEvents,
|
||||
ttlSec: resolved.eventBufferTtlSec,
|
||||
});
|
||||
},
|
||||
healthz(): BrokerHealthSnapshot {
|
||||
return evaluateBrokerHealth(publisher, subscriber, lastError);
|
||||
},
|
||||
isDisposed(): boolean {
|
||||
return disposed;
|
||||
},
|
||||
};
|
||||
return broker;
|
||||
}
|
||||
Reference in New Issue
Block a user