328 lines
11 KiB
TypeScript
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;
|
|
}
|