Files
suaveplan/packages/api-stack/api/api-ws-broker-redis/src/broker/broker.ts
2026-05-27 23:29:53 +01:00

328 lines
11 KiB
TypeScript

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;
}