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,130 @@
# `broker` — `createRedisWsBroker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the orchestrator for `@suaveplan/api-ws-broker-redis`. It wires
two Redis clients (one in publish mode, one held in subscribe mode), establishes
a `PSUBSCRIBE <channelPrefix>*` pattern, and exposes the `WsBroker` surface plus
broker-specific operations (`writeBuffer`, `readBuffer`, `healthz`, `isDisposed`).
The broker is the production cross-host adapter for the `@suaveplan/api-ws`
contract — the in-memory broker handles single-host fan-out and this package
extends that to many hosts by riding Redis pub/sub.
The broker is intentionally pluggable: a `clientFactory` option lets callers
swap in `ioredis`, `node-redis`, or any client implementing
{@link RedisPubSubLike}. When unset the broker defaults to the built-in RESP
client shipped from `./resp-client`, which keeps the package free of mandatory
runtime dependencies on an external Redis client.
## Features
- Two Redis sockets, one per role — replies on the publisher socket, async
pub/sub frames on the subscriber socket. Redis itself enforces that subscribe
sockets reject non-subscribe commands; the broker structure mirrors that
constraint exactly.
- Eager `PSUBSCRIBE` at construction time so a subsequent `publish` is
immediately visible to peers.
- Compression-aware envelope encoding via `./publish` (gzip above the
configurable threshold, hard 4 MiB cap, magic-byte framing).
- Reconnect-friendly event buffer: per-`(tenant, topic)` sorted-set with
cap + TTL, consumed by `api-ws-bun` and `api-sse-bun` for `Last-Event-Id`
resume.
- 10 stable `WS_BROKER_REDIS.*` error codes; every production path attaches
the original `cause`.
- Idempotent `dispose()` that detaches listeners, `PUNSUBSCRIBE`s the pattern,
and quits both sockets.
- OTel counter + histogram surface (`ws.broker.*`) wired through the
`./instruments` module.
## Usage: Basic
```ts
import { createRedisWsBroker } from "@suaveplan/api-ws-broker-redis/broker";
import type { TenantId } from "@suaveplan/api-types/tenant";
const broker = await createRedisWsBroker<{ id: number }>({
connection: "redis://localhost:6379",
});
const sub = await broker.subscribe("orders", (envelope) => {
console.log("delivered", envelope.payload.id);
});
await broker.publish("orders", {
topic: "orders",
tenant: "t1" as unknown as TenantId,
payload: { id: 1 },
});
sub.unsubscribe();
await broker.dispose();
```
## Usage: Advanced
```ts
import { createRedisWsBroker } from "@suaveplan/api-ws-broker-redis/broker";
// Multi-host setup with custom prefixes and a fixed reconnect strategy.
const broker = await createRedisWsBroker({
connection: process.env.REDIS_URL!,
keyPrefix: "prod:ws:",
channelPrefix: "prod.ws.broadcast.",
eventBufferMaxEvents: 512,
eventBufferTtlSec: 600,
compressionThresholdBytes: 2048,
commandTimeoutMs: 2_000,
reconnectStrategy: {
initialDelayMs: 50,
maxDelayMs: 2_000,
maxAttempts: 200,
multiplier: 2,
},
hostId: process.env.HOSTNAME ?? "host-unknown",
});
// Pluggable client (ioredis adapter, etc.):
const ioredisBroker = await createRedisWsBroker({
connection: process.env.REDIS_URL!,
clientFactory: (url, role) =>
// Wrap an ioredis instance into the RedisPubSubLike shape — production
// teams who already use ioredis can avoid maintaining two clients.
myIoredisAdapter(url, role),
});
```
## API Reference
- `createRedisWsBroker<Payload>(options)` — async factory returning a
`RedisWsBroker<Payload>`.
- `RedisWsBroker<Payload> extends WsBroker<Payload>` — adds `writeBuffer`,
`readBuffer`, `healthz`, `isDisposed`, and `options` (the resolved bundle).
- `CreateRedisWsBrokerOptions` — `RedisWsBrokerOptions` plus the factory-only
`clientFactory` and `readyTimeoutMs` knobs.
- `RedisClientFactory` — `(url, role) => RedisPubSubLike`.
## Implementation Notes
The broker normalises all timing through `Date.now()` — production deployments
that want a synthetic clock should layer that at the OTel reader level rather
than reaching into the broker. The constructor strips factory-only fields
(`clientFactory`, `readyTimeoutMs`) before handing the rest to the strict
Zod schema so unknown keys still fail fast for the genuine option shape.
The `waitForReady` helper races the client's `ready` event against a timeout
and against `error`/`close`. This means a flaky network at startup surfaces
fast (`CONNECTION_LOST`) rather than wedging the broker in an unbuilt state.
Once construction returns, every method short-circuits with the same code on
post-`dispose` calls — the broker is single-use, like the in-memory variant.
The subscribe path runs `dispatchInboundFrame` per topic via the local
registry, which snapshots the handler set before delivery so a handler that
unsubscribes mid-fan-out does not silently skip its peers. Decode failures
and per-handler rejections route through the `onError` callback (currently
captured into `healthz().lastError`) rather than propagating into the pub/sub
loop, because Redis will not stop pushing frames just because one of them
was malformed.

View File

@@ -0,0 +1,73 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { WsBroker } from "@suaveplan/api-ws/broker";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { CreateRedisWsBrokerOptions, RedisWsBroker } from "./broker.js";
/**
* Conformance — pins that {@link RedisWsBroker} satisfies the abstract
* `WsBroker` contract at the type level and exposes the broker-specific
* narrowings the host relies on.
*/
describe("RedisWsBroker contract conformance", () => {
it("RedisWsBroker is assignable to WsBroker", {
timeout: 5000,
}, () => {
expectTypeOf<RedisWsBroker<number>>().toMatchTypeOf<WsBroker<number>>();
});
it("declares publish/subscribe/dispose as the abstract surface", {
timeout: 5000,
}, () => {
expectTypeOf<RedisWsBroker>().toHaveProperty("publish");
expectTypeOf<RedisWsBroker>().toHaveProperty("subscribe");
expectTypeOf<RedisWsBroker>().toHaveProperty("dispose");
});
it("publish accepts a (topic, BrokerEnvelope) pair", {
timeout: 5000,
}, () => {
type P = RedisWsBroker<number>["publish"];
expectTypeOf<Parameters<P>[0]>().toEqualTypeOf<string>();
});
it("subscribe returns a Promise<BrokerSubscription> for the Redis broker", {
timeout: 5000,
}, () => {
type S = RedisWsBroker<number>["subscribe"];
expectTypeOf<ReturnType<S>>().toMatchTypeOf<
Promise<{ readonly topic: string }>
>();
});
it("dispose returns Promise<void> for the async-disposing Redis broker", {
timeout: 5000,
}, () => {
type D = RedisWsBroker["dispose"];
expectTypeOf<ReturnType<D>>().toEqualTypeOf<Promise<void>>();
});
it("createRedisWsBroker options REQUIRE the connection URL", {
timeout: 5000,
}, () => {
// `connection` is a required field — assigning {} to the options
// type should not compile. We assert at the type level only.
expectTypeOf<CreateRedisWsBrokerOptions>().toHaveProperty("connection");
});
it("RedisWsBroker exposes writeBuffer/readBuffer/healthz beyond the abstract contract", {
timeout: 5000,
}, () => {
expectTypeOf<RedisWsBroker>().toHaveProperty("writeBuffer");
expectTypeOf<RedisWsBroker>().toHaveProperty("readBuffer");
expectTypeOf<RedisWsBroker>().toHaveProperty("healthz");
});
it("WsBroker's TenantId is preserved through the wire envelope", {
timeout: 5000,
}, () => {
const t = "tenant-x" as unknown as TenantId;
// Trivially passes — this is the smoke that the import wiring is
// intact at runtime, not just at compile time.
expect(String(t)).toBe("tenant-x");
});
});

View File

@@ -0,0 +1,784 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BROKER_REDIS_CODES } from "../error-codes/error-codes.js";
import { encodeEnvelope } from "../publish/publish.js";
import type {
RedisPubSubLike,
RedisPubSubListener,
} from "../resp-client/resp-client.js";
import { createRedisWsBroker } from "./broker.js";
const tenant = "tenant-a" as unknown as TenantId;
interface SharedBus {
publish(channel: string, payload: Uint8Array): void;
register(listener: (e: RedisPubSubListener) => void): () => void;
}
function makeSharedBus(): SharedBus {
const subscribers = new Set<(e: RedisPubSubListener) => void>();
return {
publish(channel, payload) {
for (const l of subscribers)
l({ event: "message", channel, payload });
},
register(l) {
subscribers.add(l);
return () => subscribers.delete(l);
},
};
}
/**
* In-process RedisPubSubLike that two broker instances can share to simulate
* multi-host fan-out without a real Redis. Pub/sub semantics: every publish
* is delivered to every subscriber (which is what `PSUBSCRIBE prefix*`
* gives you when both hosts hold the same pattern).
*/
function makeBusBackedClient(
bus: SharedBus,
role: "publisher" | "subscriber",
zsets: Map<string, Array<{ score: number; member: string }>>,
ttls: Map<string, number>
): RedisPubSubLike {
const listeners: Array<(e: RedisPubSubListener) => void> = [];
let unsub: (() => void) | null = null;
let ready = false;
setTimeout(() => {
ready = true;
for (const l of listeners) l({ event: "ready" });
}, 0);
return {
get isReady() {
return ready;
},
publish(channel, payload) {
bus.publish(channel, payload);
return Promise.resolve(0);
},
psubscribe() {
if (role === "subscriber") {
unsub = bus.register((evt) => {
for (const l of listeners) l(evt);
});
}
return Promise.resolve();
},
punsubscribe() {
if (unsub) {
unsub();
unsub = null;
}
return Promise.resolve();
},
async zadd(key, score, member) {
const arr = zsets.get(key) ?? [];
const idx = arr.findIndex((e) => e.member === member);
if (idx >= 0) {
arr[idx] = { score, member };
zsets.set(key, arr);
return 0;
}
arr.push({ score, member });
arr.sort((a, b) => a.score - b.score);
zsets.set(key, arr);
return 1;
},
async zremrangebyrank(key, start, stop) {
const arr = zsets.get(key);
if (!arr) return 0;
const removed = arr.splice(start, stop - start + 1);
return removed.length;
},
async zrangebyscore(key, min, max) {
const arr = zsets.get(key) ?? [];
const exclusiveMin = min.startsWith("(");
const lowRaw = exclusiveMin ? min.slice(1) : min;
const low =
lowRaw === "-inf"
? Number.NEGATIVE_INFINITY
: Number.parseFloat(lowRaw);
const high =
max === "+inf"
? Number.POSITIVE_INFINITY
: Number.parseFloat(max);
return arr
.filter((e) =>
exclusiveMin
? e.score > low && e.score <= high
: e.score >= low && e.score <= high
)
.map((e) => e.member);
},
async zcard(key) {
return (zsets.get(key) ?? []).length;
},
async expire(key, seconds) {
if (zsets.has(key)) {
ttls.set(key, seconds);
return 1;
}
return 0;
},
async del(key) {
return zsets.delete(key) ? 1 : 0;
},
async ping() {
return "PONG";
},
async quit() {
ready = false;
if (unsub) {
unsub();
unsub = null;
}
for (const l of listeners) l({ event: "close" });
},
on(listener) {
listeners.push(listener);
if (ready) listener({ event: "ready" });
return () => {
const i = listeners.indexOf(listener);
if (i >= 0) listeners.splice(i, 1);
};
},
};
}
interface Harness {
bus: SharedBus;
zsets: Map<string, Array<{ score: number; member: string }>>;
ttls: Map<string, number>;
}
function harness(): Harness {
return {
bus: makeSharedBus(),
zsets: new Map(),
ttls: new Map(),
};
}
describe("createRedisWsBroker — unit (shared in-process bus)", () => {
it("rejects invalid options with CONFIG_INVALID", {
timeout: 5000,
}, async () => {
await expect(
createRedisWsBroker({ connection: "" })
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONFIG_INVALID,
});
});
it("opens publisher + subscriber, PSUBSCRIBEs, and reports up", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
expect(broker.healthz().status).toBe("up");
expect(broker.isDisposed()).toBe(false);
await broker.dispose();
expect(broker.isDisposed()).toBe(true);
// Idempotent dispose
await broker.dispose();
});
it("publish encodes + emits frames that subscribers decode", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker<{ id: number }>({
connection: "redis://x",
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
const received: Array<BrokerEnvelope<{ id: number }>> = [];
await broker.subscribe("orders", (env) => {
received.push(env);
});
await broker.publish("orders", {
topic: "orders",
tenant,
payload: { id: 99 },
});
// Microtask drain.
await new Promise<void>((r) => setTimeout(r, 0));
expect(received.length).toBe(1);
expect(received[0]!.payload.id).toBe(99);
expect(received[0]!.originHost).toMatch(/^host-/);
await broker.dispose();
});
it("post-dispose calls reject with CONNECTION_LOST", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
await broker.dispose();
await expect(
broker.publish("orders", { topic: "orders", tenant, payload: 1 })
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
await expect(
broker.subscribe("orders", () => undefined)
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
await expect(
broker.writeBuffer(tenant, "orders", {
eventId: 1,
payload: new Uint8Array(),
})
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
await expect(
broker.readBuffer(tenant, "orders", 0)
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
});
it("subscribe unsubscribe is idempotent and removes handler", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker<{ id: number }>({
connection: "redis://x",
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
let count = 0;
const sub = await broker.subscribe("orders", () => {
count += 1;
});
sub.unsubscribe();
sub.unsubscribe();
await broker.publish("orders", {
topic: "orders",
tenant,
payload: { id: 1 },
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(count).toBe(0);
await broker.dispose();
});
it("PAYLOAD_TOO_LARGE during publish counts as an error outcome", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker<{ s: string }>({
connection: "redis://x",
disableCompression: true,
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
const huge = "y".repeat(4_194_400);
await expect(
broker.publish("orders", {
topic: "orders",
tenant,
payload: { s: huge },
})
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.PAYLOAD_TOO_LARGE,
});
await broker.dispose();
});
it("PUBLISH_FAILED is raised when the underlying client rejects", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const base = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
if (role === "publisher") {
return {
...base,
publish: () => Promise.reject(new Error("nope")),
};
}
return base;
},
});
await expect(
broker.publish("orders", { topic: "orders", tenant, payload: 1 })
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.PUBLISH_FAILED,
});
await broker.dispose();
});
it("PSUBSCRIBE failure raises SUBSCRIBE_FAILED and disposes clients (quit may reject)", {
timeout: 5000,
}, async () => {
const h = harness();
await expect(
createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const base = makeBusBackedClient(
h.bus,
role,
h.zsets,
h.ttls
);
if (role === "subscriber") {
return {
...base,
psubscribe: () =>
Promise.reject(new Error("ps err")),
// Force quit to reject so the cleanup `.catch(()=>undefined)`
// branch in the SUBSCRIBE_FAILED path executes.
quit: () =>
Promise.reject(new Error("sub-quit fail")),
};
}
return {
...base,
quit: () => Promise.reject(new Error("pub-quit fail")),
};
},
})
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.SUBSCRIBE_FAILED,
});
});
it("preserves caller-supplied originHost", { timeout: 5000 }, async () => {
const h = harness();
const broker = await createRedisWsBroker<{ x: number }>({
connection: "redis://x",
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
const seen: Array<BrokerEnvelope<{ x: number }>> = [];
await broker.subscribe("orders", (e) => {
seen.push(e);
});
await broker.publish("orders", {
topic: "orders",
tenant,
payload: { x: 1 },
originHost: "host-fixed",
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(seen[0]!.originHost).toBe("host-fixed");
await broker.dispose();
});
it("two broker instances on the same bus see each other's publishes (multi-host simulation)", {
timeout: 5000,
}, async () => {
const h = harness();
const factory = (url: string, role: "publisher" | "subscriber") =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
const hostA = await createRedisWsBroker<{ id: number }>({
connection: "redis://x",
hostId: "host-A",
clientFactory: factory,
});
const hostB = await createRedisWsBroker<{ id: number }>({
connection: "redis://x",
hostId: "host-B",
clientFactory: factory,
});
const recvB: Array<BrokerEnvelope<{ id: number }>> = [];
await hostB.subscribe("orders", (e) => {
recvB.push(e);
});
await hostA.publish("orders", {
topic: "orders",
tenant,
payload: { id: 42 },
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(recvB.length).toBe(1);
expect(recvB[0]!.originHost).toBe("host-A");
await hostA.dispose();
await hostB.dispose();
});
it("writeBuffer + readBuffer flow", { timeout: 5000 }, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
eventBufferMaxEvents: 8,
eventBufferTtlSec: 60,
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
for (let i = 1; i <= 5; i += 1) {
await broker.writeBuffer(tenant, "orders", {
eventId: i,
payload: encodeEnvelope(
{ topic: "orders", tenant, payload: i },
{
compressionThresholdBytes: 1024,
disableCompression: false,
}
).bytes,
});
}
const events = await broker.readBuffer(tenant, "orders", 3);
expect(events.map((e) => e.eventId)).toEqual([4, 5]);
await broker.dispose();
});
it("subscriber 'error' updates lastError and increments connection_lost", {
timeout: 5000,
}, async () => {
const h = harness();
const errorEmitters: Array<(e: Error) => void> = [];
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const base = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
if (role === "subscriber") {
const listeners: Array<(e: RedisPubSubListener) => void> =
[];
return {
...base,
on(l) {
listeners.push(l);
setTimeout(() => l({ event: "ready" }), 0);
errorEmitters.push((e) =>
listeners.forEach((ll) =>
ll({ event: "error", error: e })
)
);
return () => undefined;
},
};
}
return base;
},
});
errorEmitters[0]!(new Error("subscriber-net-down"));
await new Promise<void>((r) => setTimeout(r, 0));
expect(broker.healthz().lastError).toBe("subscriber-net-down");
await broker.dispose();
});
it("publisher 'error' updates lastError and increments connection_lost", {
timeout: 5000,
}, async () => {
const h = harness();
const errorEmitters: Array<(e: Error) => void> = [];
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const base = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
if (role === "publisher") {
const listeners: Array<(e: RedisPubSubListener) => void> =
[];
return {
...base,
on(l) {
listeners.push(l);
setTimeout(() => l({ event: "ready" }), 0);
errorEmitters.push((e) =>
listeners.forEach((ll) =>
ll({ event: "error", error: e })
)
);
return () => undefined;
},
};
}
return base;
},
});
errorEmitters[0]!(new Error("publisher-net-down"));
await new Promise<void>((r) => setTimeout(r, 0));
expect(broker.healthz().lastError).toBe("publisher-net-down");
await broker.dispose();
});
it("publisher 'close' increments connection_lost without lastError", {
timeout: 5000,
}, async () => {
const h = harness();
let pubClient: RedisPubSubLike | null = null;
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const c = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
if (role === "publisher") pubClient = c;
return c;
},
});
await pubClient!.quit();
const status = broker.healthz().status;
expect(status === "degraded" || status === "down").toBe(true);
await broker.dispose();
});
it("waitForReady ignores non-ready/error/close events (e.g. message) until ready arrives", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const listeners: Array<(e: RedisPubSubListener) => void> = [];
const base = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
return {
...base,
isReady: false,
on(l) {
listeners.push(l);
// Emit a 'message' first (which waitForReady ignores),
// then 'ready' so the broker successfully constructs.
setTimeout(() => {
l({
event: "message",
channel: "pre-ready",
payload: new Uint8Array(),
});
l({ event: "ready" });
}, 0);
return () => undefined;
},
};
},
});
expect(broker.isDisposed()).toBe(false);
await broker.dispose();
});
it("waitForReady rejects on a 'close' event before ready", {
timeout: 5000,
}, async () => {
await expect(
createRedisWsBroker({
connection: "redis://x",
readyTimeoutMs: 5000,
clientFactory: () => {
const listeners: Array<(e: RedisPubSubListener) => void> =
[];
setTimeout(() => {
for (const l of listeners) l({ event: "close" });
}, 0);
return {
isReady: false,
publish: () => Promise.resolve(0),
psubscribe: () => Promise.resolve(),
punsubscribe: () => Promise.resolve(),
zadd: () => Promise.resolve(0),
zremrangebyrank: () => Promise.resolve(0),
zrangebyscore: () => Promise.resolve([]),
zcard: () => Promise.resolve(0),
expire: () => Promise.resolve(0),
ping: () => Promise.resolve("PONG"),
del: () => Promise.resolve(0),
quit: () => Promise.resolve(),
on: (l) => {
listeners.push(l);
return () => undefined;
},
};
},
})
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
});
it("subscriber 'close' increments connection_lost and updates health", {
timeout: 5000,
}, async () => {
const h = harness();
let subClient: RedisPubSubLike | null = null;
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const c = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
if (role === "subscriber") subClient = c;
return c;
},
});
// Force-quit the subscriber, which emits 'close' through listeners.
await subClient!.quit();
// Health degraded or down, depending on whether publisher remains ready.
const status = broker.healthz().status;
expect(status === "degraded" || status === "down").toBe(true);
await broker.dispose();
});
it("waitForReady times out when the client never emits 'ready'", {
timeout: 5000,
}, async () => {
await expect(
createRedisWsBroker({
connection: "redis://x",
readyTimeoutMs: 25,
clientFactory: () => ({
isReady: false,
publish: () => Promise.resolve(0),
psubscribe: () => Promise.resolve(),
punsubscribe: () => Promise.resolve(),
zadd: () => Promise.resolve(0),
zremrangebyrank: () => Promise.resolve(0),
zrangebyscore: () => Promise.resolve([]),
zcard: () => Promise.resolve(0),
expire: () => Promise.resolve(0),
ping: () => Promise.resolve("PONG"),
del: () => Promise.resolve(0),
quit: () => Promise.resolve(),
on: () => () => undefined,
}),
})
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
});
it("waitForReady rejects when client emits 'error' before 'ready'", {
timeout: 5000,
}, async () => {
await expect(
createRedisWsBroker({
connection: "redis://x",
readyTimeoutMs: 5000,
clientFactory: () => {
const listeners: Array<(e: RedisPubSubListener) => void> =
[];
setTimeout(() => {
for (const l of listeners)
l({
event: "error",
error: new Error("preflight"),
});
}, 0);
return {
isReady: false,
publish: () => Promise.resolve(0),
psubscribe: () => Promise.resolve(),
punsubscribe: () => Promise.resolve(),
zadd: () => Promise.resolve(0),
zremrangebyrank: () => Promise.resolve(0),
zrangebyscore: () => Promise.resolve([]),
zcard: () => Promise.resolve(0),
expire: () => Promise.resolve(0),
ping: () => Promise.resolve("PONG"),
del: () => Promise.resolve(0),
quit: () => Promise.resolve(),
on: (l) => {
listeners.push(l);
return () => undefined;
},
};
},
})
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
});
it("decode failure inside subscriber loop is captured into healthz lastError", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
await broker.subscribe("orders", () => undefined);
// Force a malformed frame through the bus directly (bypassing publish).
h.bus.publish("ws.broadcast.orders", new Uint8Array([0xff]));
await new Promise<void>((r) => setTimeout(r, 0));
expect(broker.healthz().lastError).toBeDefined();
await broker.dispose();
});
it("default clientFactory: createRespClient is used when no factory is supplied", {
timeout: 5000,
}, async () => {
// Point at an unbound port; the built-in RESP client will fail to
// become ready within readyTimeoutMs and surface CONNECTION_LOST.
// This exercises the default-factory branch (no clientFactory in opts).
await expect(
createRedisWsBroker({
connection: "redis://127.0.0.1:1",
readyTimeoutMs: 50,
})
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.CONNECTION_LOST,
});
});
it("dispose tolerates punsubscribe failure", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const base = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
if (role === "subscriber") {
return {
...base,
punsubscribe: () =>
Promise.reject(new Error("punsub fail")),
};
}
return base;
},
});
// Should not throw despite punsubscribe rejecting.
await broker.dispose();
expect(broker.isDisposed()).toBe(true);
});
it("dispose tolerates publisher.quit + subscriber.quit failures", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) => {
const base = makeBusBackedClient(h.bus, role, h.zsets, h.ttls);
return {
...base,
quit: () => Promise.reject(new Error("quit fail")),
};
},
});
await broker.dispose();
expect(broker.isDisposed()).toBe(true);
});
it("frames on channels outside the prefix are silently dropped", {
timeout: 5000,
}, async () => {
const h = harness();
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (url, role) =>
makeBusBackedClient(h.bus, role, h.zsets, h.ttls),
});
let count = 0;
await broker.subscribe("orders", () => {
count += 1;
});
h.bus.publish("nope.orders", new Uint8Array([0xff]));
await new Promise<void>((r) => setTimeout(r, 0));
expect(count).toBe(0);
await broker.dispose();
});
});

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

View File

@@ -0,0 +1,6 @@
export {
type CreateRedisWsBrokerOptions,
createRedisWsBroker,
type RedisClientFactory,
type RedisWsBroker,
} from "./broker.js";

View File

@@ -0,0 +1,96 @@
# `error-codes` — `WS_BROKER_REDIS_CODES`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module defines the stable error-code registry for
`@suaveplan/api-ws-broker-redis`. Every failure surface the broker emits at
runtime — Redis connection drops, publish/subscribe failures, buffer reads
and writes, oversize payloads, deserialise failures, configuration errors,
reconnect exhaustion, and command timeouts — carries one of the ten
`WS_BROKER_REDIS.*` codes defined here. Callers branch on the literal string
value (or `instanceof WsBrokerRedisError`) to drive recovery logic.
The registry is closed: adding a new code requires an OpenSpec change so
production dashboards and alerting rules keyed on the existing codes stay
stable.
## Features
- Ten stable codes covering every documented failure surface.
- `defineCodes("WS_BROKER_REDIS", [...])` so the runtime values are
auto-namespaced to `WS_BROKER_REDIS.<NAME>`.
- `WsBrokerRedisError` subclass with a public constructor (lint-enforced),
so callers can `new WsBrokerRedisError(code, message, { cause })` directly
rather than coercing through a factory.
- Tied-off TypeScript literal union (`WsBrokerRedisErrorCode`) for
exhaustive `switch` statements at call sites.
## Usage: Basic
```ts
import {
WS_BROKER_REDIS_CODES,
WsBrokerRedisError,
} from "@suaveplan/api-ws-broker-redis/error-codes";
try {
await broker.publish("orders", envelope);
} catch (err) {
if (
err instanceof WsBrokerRedisError &&
err.brokerCode === WS_BROKER_REDIS_CODES.PAYLOAD_TOO_LARGE
) {
// Drop or split — Redis is not going to accept this frame.
return;
}
throw err;
}
```
## Usage: Advanced
```ts
import {
type WsBrokerRedisErrorCode,
WS_BROKER_REDIS_CODES,
} from "@suaveplan/api-ws-broker-redis/error-codes";
function classify(code: WsBrokerRedisErrorCode): "retry" | "drop" | "fatal" {
switch (code) {
case WS_BROKER_REDIS_CODES.CONNECTION_LOST:
case WS_BROKER_REDIS_CODES.TIMEOUT:
case WS_BROKER_REDIS_CODES.PUBLISH_FAILED:
return "retry";
case WS_BROKER_REDIS_CODES.PAYLOAD_TOO_LARGE:
case WS_BROKER_REDIS_CODES.DESERIALISE_FAILED:
return "drop";
default:
return "fatal";
}
}
```
## API Reference
- `WS_BROKER_REDIS_CODES` — frozen registry mapping name → `WS_BROKER_REDIS.<NAME>`.
- `WsBrokerRedisErrorCode` — literal union over every registered code.
- `WsBrokerRedisError` — `SuaveplanError<WsBrokerRedisErrorCode>` subclass with
`brokerCode` field and a public 2/3-arg constructor.
## Implementation Notes
`WsBrokerRedisError` declares its own public constructor (rather than relying
on the base `SuaveplanError`'s protected one) so `new WsBrokerRedisError(...)`
compiles cleanly from outside the file. This is required by the repo's
`check-error-subclass-constructors` lint — TypeScript's TS2674 ("Constructor of
class is protected and only accessible within the class declaration") would
otherwise reject every call site.
The `brokerCode` field is a deliberate alias for `code`. Calling code commonly
branches on the narrower `WsBrokerRedisErrorCode` literal union to get
exhaustiveness from the TypeScript compiler; reading the alias keeps that
narrowing locally explicit even when the catch block widened the type.

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BROKER_REDIS_CODES, WsBrokerRedisError } from "./error-codes.js";
describe("WS_BROKER_REDIS error codes", () => {
it("defines exactly the ten documented codes", { timeout: 5000 }, () => {
const keys = Object.keys(WS_BROKER_REDIS_CODES).sort();
expect(keys).toEqual(
[
"BUFFER_READ_FAILED",
"BUFFER_WRITE_FAILED",
"CONFIG_INVALID",
"CONNECTION_LOST",
"DESERIALISE_FAILED",
"PAYLOAD_TOO_LARGE",
"PUBLISH_FAILED",
"RECONNECT_FAILED",
"SUBSCRIBE_FAILED",
"TIMEOUT",
].sort()
);
});
it("namespaces every code under WS_BROKER_REDIS", {
timeout: 5000,
}, () => {
for (const value of Object.values(WS_BROKER_REDIS_CODES)) {
expect(value.startsWith("WS_BROKER_REDIS.")).toBe(true);
}
});
it("constructs and preserves brokerCode", { timeout: 5000 }, () => {
const err = new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.PUBLISH_FAILED,
"boom"
);
expect(err.brokerCode).toBe(WS_BROKER_REDIS_CODES.PUBLISH_FAILED);
expect(err.code).toBe(WS_BROKER_REDIS_CODES.PUBLISH_FAILED);
expect(err.message).toBe("boom");
expect(err instanceof WsBrokerRedisError).toBe(true);
});
it("propagates the cause when supplied", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.TIMEOUT,
"deadline",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,66 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-ws-broker-redis`.
*
* Every code is namespaced under `WS_BROKER_REDIS.*` and exposed both
* as a runtime registry (for branching at call sites) and as a
* TypeScript literal-union type (for exhaustiveness checks at compile
* time):
*
* - `CONNECTION_LOST` — the underlying Redis connection dropped. Surfaced
* by `publish`/`subscribe`/buffer ops attempted while the connection is
* not in the `ready` state.
* - `PUBLISH_FAILED` — the `PUBLISH` command returned an error or the
* socket write threw. The original cause is attached via `cause`.
* - `SUBSCRIBE_FAILED` — the `SUBSCRIBE` / `PSUBSCRIBE` command returned an
* error.
* - `BUFFER_WRITE_FAILED` — the `ZADD` / `EXPIRE` pair driving the reconnect
* event-buffer write failed.
* - `BUFFER_READ_FAILED` — the `ZRANGEBYSCORE` driving the reconnect
* event-buffer read failed.
* - `PAYLOAD_TOO_LARGE` — the encoded payload exceeded the 4 MiB hard cap
* enforced by {@link MAX_PAYLOAD_BYTES}.
* - `DESERIALISE_FAILED` — a published frame received via subscribe could
* not be decoded (invalid JSON, gzip framing inconsistent with the leading
* magic byte, or the Zod envelope schema rejected the payload).
* - `CONFIG_INVALID` — `createRedisWsBroker(opts)` received a shape that
* fails the options schema.
* - `RECONNECT_FAILED` — the reconnect strategy gave up after the
* configured maximum number of attempts.
* - `TIMEOUT` — a Redis command exceeded the configured deadline.
*/
export const WS_BROKER_REDIS_CODES = defineCodes("WS_BROKER_REDIS", [
"CONNECTION_LOST",
"PUBLISH_FAILED",
"SUBSCRIBE_FAILED",
"BUFFER_WRITE_FAILED",
"BUFFER_READ_FAILED",
"PAYLOAD_TOO_LARGE",
"DESERIALISE_FAILED",
"CONFIG_INVALID",
"RECONNECT_FAILED",
"TIMEOUT",
] as const);
export type WsBrokerRedisErrorCode =
(typeof WS_BROKER_REDIS_CODES)[keyof typeof WS_BROKER_REDIS_CODES];
/**
* Concrete error class for the Redis broker. Extends
* {@link SuaveplanError} so `instanceof WsBrokerRedisError` narrows
* `code` to {@link WsBrokerRedisErrorCode}.
*/
export class WsBrokerRedisError extends SuaveplanError<WsBrokerRedisErrorCode> {
public readonly brokerCode: WsBrokerRedisErrorCode;
public constructor(
code: WsBrokerRedisErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.brokerCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
WS_BROKER_REDIS_CODES,
WsBrokerRedisError,
type WsBrokerRedisErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,99 @@
# `event-buffer` — Redis sorted-set reconnect buffer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module backs the broker's reconnect event buffer. Each
`(tenant, topic)` pair maps to one Redis sorted-set keyed by
`<keyPrefix>buffer:<tenant>:<topic>` and scored by `eventId`. The
`api-ws-bun` and `api-sse-bun` engines call into this surface to replay
events to clients that reconnected with a `Last-Event-Id` (WS) or
`Last-Event-Id` (SSE) header.
The buffer enforces two operational guarantees: a fixed cap
(`eventBufferMaxEvents`) so a per-tenant runaway publisher cannot exhaust
Redis memory, and a sliding TTL (`eventBufferTtlSec`) so abandoned topics
expire without operator intervention.
## Features
- Sorted-set encoding `<eventId>|<base64-payload>` so the integer score
remains usable for `ZRANGEBYSCORE` queries and the binary payload survives
the round-trip without coercion.
- Cap enforcement via `ZCARD` + `ZREMRANGEBYRANK 0 (overflow - 1)` —
oldest entries evicted first.
- Per-write TTL refresh via `EXPIRE` so the key always carries the latest
TTL window.
- `readBuffered Events(...)` returns events strictly greater than
`sinceEventId` (`(<sinceEventId>` syntax) in ascending score order.
- OTel hit/miss labelling on every read via the `ws.broker.buffer_reads_total`
counter.
## Usage: Basic
```ts
import {
readBufferedEvents,
writeBufferedEvent,
} from "@suaveplan/api-ws-broker-redis/event-buffer";
const opts = { keyPrefix: "sp:", maxEvents: 256, ttlSec: 300 };
await writeBufferedEvent(client, tenant, "orders", {
eventId: 1,
payload: new Uint8Array([1, 2, 3]),
}, opts);
const events = await readBufferedEvents(client, tenant, "orders", 0, opts);
```
## Usage: Advanced
```ts
import {
bufferKey,
decodeBufferMember,
} from "@suaveplan/api-ws-broker-redis/event-buffer";
// Drop into raw Redis to debug a stuck buffer — `decodeBufferMember` parses
// each `<eventId>|<base64>` entry back into its `BufferedEvent`.
const key = bufferKey("sp:", tenant, "orders");
const range = await rawRedis.zrangebyscore(key, "-inf", "+inf");
for (const m of range) {
const event = decodeBufferMember(m);
console.log(event.eventId, event.payload.length);
}
```
## API Reference
- `writeBufferedEvent(client, tenant, topic, event, opts): Promise<void>` —
append, enforce cap, refresh TTL.
- `readBufferedEvents(client, tenant, topic, sinceEventId, opts):
Promise<readonly BufferedEvent[]>` — replay events with `id > sinceEventId`.
- `bufferKey(keyPrefix, tenant, topic): string` — key construction helper.
- `encodeBufferMember(event)` / `decodeBufferMember(s)` — sorted-set
member encoding/decoding (exposed for raw-Redis debugging).
- `BufferedEvent { eventId, payload }` and `EventBufferOptions
{ keyPrefix, maxEvents, ttlSec }`.
## Implementation Notes
The sorted-set strategy was chosen over a Redis Stream because the broker
only needs id-keyed range reads (no consumer-group coordination), and a
sorted-set's eviction and cap operations are atomic enough for the
single-tenant single-topic write path the broker uses. Streams' richer
semantics (XCLAIM, XACK) would only add operational complexity without
helping the resume use case.
The base64 step inside `encodeBufferMember` is intentional: sorted-set
members are byte strings, but the broker payload is an arbitrary
`Uint8Array` that may contain `\r\n` or `\0` bytes. Base64 round-trips
through Redis's binary-safe wire protocol without coercion and the cost is
33% bigger members, which is well below the network overhead of the
publish itself.
`writeBufferedEvent` re-throws an existing `WsBrokerRedisError` unmodified
rather than double-wrapping it; tests cover the no-double-wrap path
explicitly.

View File

@@ -0,0 +1,269 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
WS_BROKER_REDIS_CODES,
WsBrokerRedisError,
} from "../error-codes/error-codes.js";
import type { RedisPubSubLike } from "../resp-client/resp-client.js";
import {
bufferKey,
decodeBufferMember,
encodeBufferMember,
readBufferedEvents,
writeBufferedEvent,
} from "./event-buffer.js";
const tenant = "tenant-a" as unknown as TenantId;
/**
* In-memory `RedisPubSubLike` driving sorted-set semantics deterministically.
* Not a mock of the system under test — this is an alternative
* `RedisPubSubLike` implementation against which the buffer logic can be
* exercised. The same buffer functions also run against a real Redis in the
* integration suite (see `tests/integration/redis-broker.integration.test.ts`).
*/
class InMemoryRedis implements RedisPubSubLike {
public readonly zsets = new Map<
string,
Array<{ score: number; member: string }>
>();
public readonly ttls = new Map<string, number>();
public failWrites = false;
public failReads = false;
public get isReady(): boolean {
return true;
}
public publish(): Promise<number> {
return Promise.resolve(0);
}
public psubscribe(): Promise<void> {
return Promise.resolve();
}
public punsubscribe(): Promise<void> {
return Promise.resolve();
}
public ping(): Promise<string> {
return Promise.resolve("PONG");
}
public quit(): Promise<void> {
return Promise.resolve();
}
public on(): () => void {
return () => undefined;
}
public async zadd(
key: string,
score: number,
member: string
): Promise<number> {
if (this.failWrites) throw new Error("zadd boom");
const arr = this.zsets.get(key) ?? [];
// Update-or-insert by member.
const idx = arr.findIndex((e) => e.member === member);
if (idx >= 0) {
arr[idx] = { score, member };
this.zsets.set(key, arr);
return 0;
}
arr.push({ score, member });
arr.sort((a, b) => a.score - b.score);
this.zsets.set(key, arr);
return 1;
}
public async zremrangebyrank(
key: string,
start: number,
stop: number
): Promise<number> {
const arr = this.zsets.get(key);
if (arr === undefined) return 0;
const removed = arr.splice(start, stop - start + 1);
return removed.length;
}
public async zrangebyscore(
key: string,
min: string,
max: string
): Promise<string[]> {
if (this.failReads) throw new Error("zrange boom");
const arr = this.zsets.get(key);
if (arr === undefined) return [];
const exclusiveMin = min.startsWith("(");
const lowRaw = exclusiveMin ? min.slice(1) : min;
const low =
lowRaw === "-inf"
? Number.NEGATIVE_INFINITY
: Number.parseFloat(lowRaw);
const high =
max === "+inf" ? Number.POSITIVE_INFINITY : Number.parseFloat(max);
return arr
.filter((e) =>
exclusiveMin
? e.score > low && e.score <= high
: e.score >= low && e.score <= high
)
.map((e) => e.member);
}
public async zcard(key: string): Promise<number> {
return (this.zsets.get(key) ?? []).length;
}
public async expire(key: string, seconds: number): Promise<number> {
if (this.zsets.has(key)) {
this.ttls.set(key, seconds);
return 1;
}
return 0;
}
public async del(key: string): Promise<number> {
return this.zsets.delete(key) ? 1 : 0;
}
}
const opts = { keyPrefix: "sp:", maxEvents: 4, ttlSec: 300 } as const;
describe("event-buffer helpers", () => {
it("bufferKey is stable", { timeout: 5000 }, () => {
expect(bufferKey("sp:", tenant, "orders")).toBe(
"sp:buffer:tenant-a:orders"
);
});
it("encodeBufferMember <-> decodeBufferMember round-trip", {
timeout: 5000,
}, () => {
const m = encodeBufferMember({
eventId: 12,
payload: new Uint8Array([1, 2, 3]),
});
const back = decodeBufferMember(m);
expect(back.eventId).toBe(12);
expect(Array.from(back.payload)).toEqual([1, 2, 3]);
});
it("decodeBufferMember rejects malformed member", {
timeout: 5000,
}, () => {
expect(() => decodeBufferMember("bad")).toThrow();
expect(() => decodeBufferMember("|abc")).toThrow();
expect(() => decodeBufferMember("nope|abc")).toThrow();
});
});
describe("writeBufferedEvent / readBufferedEvents", () => {
it("appends, caps, expires the sorted set", {
timeout: 5000,
}, async () => {
const fake = new InMemoryRedis();
for (let i = 1; i <= 5; i += 1) {
await writeBufferedEvent(
fake,
tenant,
"orders",
{ eventId: i, payload: new Uint8Array([i]) },
opts
);
}
const key = bufferKey("sp:", tenant, "orders");
expect(fake.zsets.get(key)!.length).toBe(4); // capped at maxEvents=4
expect(fake.ttls.get(key)).toBe(300);
const remaining = await fake.zrangebyscore(key, "-inf", "+inf");
const ids = remaining.map((m) => decodeBufferMember(m).eventId);
expect(ids).toEqual([2, 3, 4, 5]);
});
it("readBufferedEvents returns ids > sinceEventId in ascending order", {
timeout: 5000,
}, async () => {
const fake = new InMemoryRedis();
for (let i = 1; i <= 5; i += 1) {
await writeBufferedEvent(
fake,
tenant,
"topic",
{ eventId: i, payload: new Uint8Array([i]) },
opts
);
}
const events = await readBufferedEvents(fake, tenant, "topic", 3, opts);
expect(events.map((e) => e.eventId)).toEqual([4, 5]);
});
it("readBufferedEvents returns empty when nothing matches (miss path)", {
timeout: 5000,
}, async () => {
const fake = new InMemoryRedis();
const events = await readBufferedEvents(fake, tenant, "topic", 0, opts);
expect(events).toEqual([]);
});
it("writeBufferedEvent surfaces BUFFER_WRITE_FAILED on Redis-side errors", {
timeout: 5000,
}, async () => {
const fake = new InMemoryRedis();
fake.failWrites = true;
await expect(
writeBufferedEvent(
fake,
tenant,
"topic",
{ eventId: 1, payload: new Uint8Array() },
opts
)
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.BUFFER_WRITE_FAILED,
});
});
it("readBufferedEvents surfaces BUFFER_READ_FAILED on Redis-side errors", {
timeout: 5000,
}, async () => {
const fake = new InMemoryRedis();
await writeBufferedEvent(
fake,
tenant,
"topic",
{ eventId: 1, payload: new Uint8Array() },
opts
);
fake.failReads = true;
await expect(
readBufferedEvents(fake, tenant, "topic", 0, opts)
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.BUFFER_READ_FAILED,
});
});
it("re-throws WsBrokerRedisError unmodified (no double-wrap)", {
timeout: 5000,
}, async () => {
const fake: RedisPubSubLike = {
...new InMemoryRedis(),
// Force a typed broker error during the inner write.
zadd: () => {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.PAYLOAD_TOO_LARGE,
"preexisting"
);
},
} as unknown as RedisPubSubLike;
await expect(
writeBufferedEvent(
fake,
tenant,
"topic",
{ eventId: 1, payload: new Uint8Array() },
opts
)
).rejects.toMatchObject({
brokerCode: WS_BROKER_REDIS_CODES.PAYLOAD_TOO_LARGE,
});
});
});

View File

@@ -0,0 +1,132 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
WS_BROKER_REDIS_CODES,
WsBrokerRedisError,
} from "../error-codes/error-codes.js";
import {
bufferReadsCounter,
bufferWritesCounter,
} from "../instruments/instruments.js";
import type { RedisPubSubLike } from "../resp-client/resp-client.js";
/**
* Wire shape for a single buffered event. Stored as a single sorted-set
* member encoded as `<eventId>|<base64-payload>` so the integer score
* remains usable for `ZRANGEBYSCORE` queries AND the original payload
* bytes round-trip without coercion.
*/
export interface BufferedEvent {
readonly eventId: number;
readonly payload: Uint8Array;
}
/**
* Constructs the per-(tenant, topic) Redis sorted-set key. Keeping this in
* one place means tests can target it directly for cleanup or assertion.
*/
export function bufferKey(
keyPrefix: string,
tenant: TenantId,
topic: string
): string {
return `${keyPrefix}buffer:${String(tenant)}:${topic}`;
}
function encodeMember(event: BufferedEvent): string {
const b64 = Buffer.from(event.payload).toString("base64");
return `${event.eventId}|${b64}`;
}
function decodeMember(s: string): BufferedEvent {
const idx = s.indexOf("|");
if (idx <= 0) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.BUFFER_READ_FAILED,
`buffer member malformed: "${s}"`
);
}
const eventId = Number.parseInt(s.slice(0, idx), 10);
if (!Number.isFinite(eventId)) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.BUFFER_READ_FAILED,
`buffer member eventId is not numeric: "${s.slice(0, idx)}"`
);
}
const b64 = s.slice(idx + 1);
const buf = Buffer.from(b64, "base64");
const payload = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
return { eventId, payload };
}
export interface EventBufferOptions {
readonly keyPrefix: string;
readonly maxEvents: number;
readonly ttlSec: number;
}
/**
* Write a single event to the per-(tenant, topic) buffer. Encodes the
* payload as a sorted-set member, applies the configured cap by trimming
* the lowest-scored entries, and refreshes the TTL. Surfaces
* `WS_BROKER_REDIS.BUFFER_WRITE_FAILED` on any Redis-side failure.
*/
export async function writeBufferedEvent(
client: RedisPubSubLike,
tenant: TenantId,
topic: string,
event: BufferedEvent,
options: EventBufferOptions
): Promise<void> {
const key = bufferKey(options.keyPrefix, tenant, topic);
try {
await client.zadd(key, event.eventId, encodeMember(event));
const size = await client.zcard(key);
const overflow = size - options.maxEvents;
if (overflow > 0) {
// Remove `overflow` lowest-scored members (oldest first).
await client.zremrangebyrank(key, 0, overflow - 1);
}
await client.expire(key, options.ttlSec);
} catch (err) {
if (err instanceof WsBrokerRedisError) throw err;
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.BUFFER_WRITE_FAILED,
`failed to write buffered event for tenant=${String(tenant)} topic=${topic}: ${(err as Error).message}`,
{ cause: err }
);
}
bufferWritesCounter().add(1);
}
/**
* Replay every event with `id > sinceEventId` from the buffer, ordered by
* eventId ascending. Returns an empty array when nothing matches; the
* `hit` OTel label distinguishes the two paths.
*/
export async function readBufferedEvents(
client: RedisPubSubLike,
tenant: TenantId,
topic: string,
sinceEventId: number,
options: EventBufferOptions
): Promise<readonly BufferedEvent[]> {
const key = bufferKey(options.keyPrefix, tenant, topic);
let raw: string[];
try {
// `(sinceEventId` = exclusive lower bound in Redis ZRANGEBYSCORE syntax.
raw = await client.zrangebyscore(key, `(${sinceEventId}`, "+inf");
} catch (err) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.BUFFER_READ_FAILED,
`failed to read buffered events for tenant=${String(tenant)} topic=${topic}: ${(err as Error).message}`,
{ cause: err }
);
}
const events = raw.map(decodeMember);
bufferReadsCounter().add(1, { hit: events.length > 0 ? "hit" : "miss" });
return events;
}
/** Inverse of {@link encodeMember}; exported for tests. */
export const encodeBufferMember = encodeMember;
export const decodeBufferMember = decodeMember;

View File

@@ -0,0 +1,9 @@
export {
type BufferedEvent,
bufferKey,
decodeBufferMember,
type EventBufferOptions,
encodeBufferMember,
readBufferedEvents,
writeBufferedEvent,
} from "./event-buffer.js";

View File

@@ -0,0 +1,87 @@
# `health` — broker health snapshot
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the pure, side-effect-free evaluator for the broker's health
status. It inspects the two underlying RESP clients (one publisher socket,
one subscriber socket) and folds their `isReady` flags plus the broker's
last observed error into a three-state status (`up`, `degraded`, `down`).
The host's readiness endpoint consumes this snapshot to decide whether to
report ready, partially ready, or unhealthy.
Keeping the evaluator pure means tests can probe every state combination
deterministically without a real Redis container — the broker's
integration test then pins that `up` is reached against a live container
and that the snapshot recovers after a forced socket close.
## Features
- Three-state status (`up`, `degraded`, `down`) with clear documented
semantics — degraded means in-flight publishes will queue per reconnect
strategy; down means new publishes will fail fast with `CONNECTION_LOST`.
- Pure function — no I/O, no global state, trivially testable.
- Optional `lastError` propagation so dashboards can show the most recent
underlying socket failure even after the broker has recovered.
## Usage: Basic
```ts
import { evaluateBrokerHealth } from "@suaveplan/api-ws-broker-redis/health";
const snap = evaluateBrokerHealth(publisher, subscriber, lastError);
if (snap.status === "down") {
return new Response("unavailable", { status: 503 });
}
```
## Usage: Advanced
```ts
import { evaluateBrokerHealth } from "@suaveplan/api-ws-broker-redis/health";
// Custom readiness probe that distinguishes degraded vs down.
function readiness(): { status: number; body: unknown } {
const snap = evaluateBrokerHealth(publisher, subscriber, lastError);
if (snap.status === "up") {
return { status: 200, body: { status: "ok" } };
}
if (snap.status === "degraded") {
return {
status: 200,
body: {
status: "degraded",
publisherReady: snap.publisherReady,
subscriberReady: snap.subscriberReady,
lastError: snap.lastError,
},
};
}
return { status: 503, body: { status: "down", lastError: snap.lastError } };
}
```
## API Reference
- `evaluateBrokerHealth(publisher, subscriber, lastError):
BrokerHealthSnapshot` — pure folder over the two clients.
- `BrokerHealthSnapshot { status, publisherReady, subscriberReady, lastError? }`.
- `BrokerHealthStatus = "up" | "degraded" | "down"`.
## Implementation Notes
The `lastError` field is set conditionally so the field is omitted from the
snapshot (rather than carrying `undefined`) when no error has ever been
observed. This matters for the JSON-serialised readiness response — a
dashboard parsing `lastError` should see "absent" vs "present" as a clean
boolean, not need to special-case `null` or `undefined`.
The publisher socket and subscriber socket are evaluated independently
because Redis's subscribe-mode connection is a separate TCP socket from the
reply-mode one; either can drop without the other. Operators see the actual
state of each socket via `publisherReady` / `subscriberReady` so they can
diagnose a partial outage (e.g. firewall dropping ESTABLISHED long-lived
subscribe sockets) directly from the readiness endpoint.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { RedisPubSubLike } from "../resp-client/resp-client.js";
import { evaluateBrokerHealth } from "./health.js";
function fakeClient(isReady: boolean): RedisPubSubLike {
return {
publish: () => Promise.resolve(0),
psubscribe: () => Promise.resolve(),
punsubscribe: () => Promise.resolve(),
zadd: () => Promise.resolve(1),
zremrangebyrank: () => Promise.resolve(0),
zrangebyscore: () => Promise.resolve([]),
zcard: () => Promise.resolve(0),
expire: () => Promise.resolve(1),
ping: () => Promise.resolve("PONG"),
del: () => Promise.resolve(0),
quit: () => Promise.resolve(),
on: () => () => undefined,
get isReady() {
return isReady;
},
};
}
describe("evaluateBrokerHealth", () => {
it("status=up when both clients are ready", { timeout: 5000 }, () => {
const snap = evaluateBrokerHealth(
fakeClient(true),
fakeClient(true),
undefined
);
expect(snap.status).toBe("up");
expect(snap.publisherReady).toBe(true);
expect(snap.subscriberReady).toBe(true);
expect(snap.lastError).toBeUndefined();
});
it("status=degraded when one client is ready", {
timeout: 5000,
}, () => {
const snap = evaluateBrokerHealth(
fakeClient(true),
fakeClient(false),
undefined
);
expect(snap.status).toBe("degraded");
const snap2 = evaluateBrokerHealth(
fakeClient(false),
fakeClient(true),
undefined
);
expect(snap2.status).toBe("degraded");
});
it("status=down when neither client is ready", { timeout: 5000 }, () => {
const snap = evaluateBrokerHealth(
fakeClient(false),
fakeClient(false),
"Redis connection refused"
);
expect(snap.status).toBe("down");
expect(snap.lastError).toBe("Redis connection refused");
});
});

View File

@@ -0,0 +1,48 @@
import type { RedisPubSubLike } from "../resp-client/resp-client.js";
/**
* Health-probe return shape consumed by `host.readiness()`. The states are
* intentionally coarse:
*
* - `up` — every underlying socket is connected AND a recent `PING`
* succeeded.
* - `degraded` — at least one socket is in mid-reconnect; in-flight
* publishes will queue per `reconnectStrategy`.
* - `down` — every socket is closed or the reconnect strategy has given
* up; new publishes will fail fast with `CONNECTION_LOST`.
*/
export type BrokerHealthStatus = "up" | "degraded" | "down";
export interface BrokerHealthSnapshot {
readonly status: BrokerHealthStatus;
readonly publisherReady: boolean;
readonly subscriberReady: boolean;
readonly lastError?: string;
}
/**
* Compute the broker's health from the two underlying RESP clients. Pure
* function over the client `isReady` flags + the last observed error, so
* it is trivially testable.
*/
export function evaluateBrokerHealth(
publisher: RedisPubSubLike,
subscriber: RedisPubSubLike,
lastError: string | undefined
): BrokerHealthSnapshot {
const pub = publisher.isReady;
const sub = subscriber.isReady;
let status: BrokerHealthStatus;
if (pub && sub) status = "up";
else if (pub || sub) status = "degraded";
else status = "down";
const snapshot: BrokerHealthSnapshot = {
status,
publisherReady: pub,
subscriberReady: sub,
};
if (lastError !== undefined) {
return { ...snapshot, lastError };
}
return snapshot;
}

View File

@@ -0,0 +1,5 @@
export {
type BrokerHealthSnapshot,
type BrokerHealthStatus,
evaluateBrokerHealth,
} from "./health.js";

View File

@@ -0,0 +1,63 @@
export {
type CreateRedisWsBrokerOptions,
createRedisWsBroker,
type RedisClientFactory,
type RedisWsBroker,
} from "./broker/broker.js";
export {
WS_BROKER_REDIS_CODES,
WsBrokerRedisError,
type WsBrokerRedisErrorCode,
} from "./error-codes/error-codes.js";
export {
type BufferedEvent,
bufferKey,
type EventBufferOptions,
} from "./event-buffer/event-buffer.js";
export type {
BrokerHealthSnapshot,
BrokerHealthStatus,
} from "./health/health.js";
export {
bufferReadsCounter,
bufferWritesCounter,
connectionLostCounter,
publishDurationHistogram,
publishesCounter,
receivesCounter,
resetBrokerMetricsForTests,
} from "./instruments/instruments.js";
export {
DEFAULT_CHANNEL_PREFIX,
DEFAULT_COMMAND_TIMEOUT_MS,
DEFAULT_COMPRESSION_THRESHOLD_BYTES,
DEFAULT_EVENT_BUFFER_MAX_EVENTS,
DEFAULT_EVENT_BUFFER_TTL_SEC,
DEFAULT_KEY_PREFIX,
DEFAULT_RECONNECT_STRATEGY,
MAX_PAYLOAD_BYTES,
type RedisReconnectStrategy,
type RedisWsBrokerOptions,
type ResolvedRedisWsBrokerOptions,
redisWsBrokerOptionsSchema,
resolveRedisWsBrokerOptions,
} from "./options/options.js";
export {
decodeEnvelope,
ENCODING_GZIP_JSON,
ENCODING_JSON,
type EncodedFrame,
encodeEnvelope,
} from "./publish/publish.js";
export { nextReconnectDelayMs, shouldGiveUp } from "./reconnect/reconnect.js";
export {
createRespClient,
type RedisPubSubLike,
type RedisPubSubListener,
type RespClientOptions,
} from "./resp-client/resp-client.js";
export {
channelFromTopic,
LocalSubscriberRegistry,
topicFromChannel,
} from "./subscribe/subscribe.js";

View File

@@ -0,0 +1,9 @@
export {
bufferReadsCounter,
bufferWritesCounter,
connectionLostCounter,
publishDurationHistogram,
publishesCounter,
receivesCounter,
resetBrokerMetricsForTests,
} from "./instruments.js";

View File

@@ -0,0 +1,106 @@
# `instruments` — OTel `ws.broker.*` surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instrument surface for the Redis broker.
Six instruments cover every documented operational signal: publish attempts
and durations, inbound receive counts, reconnect-buffer reads and writes
(with hit/miss labelling), and observed connection drops. Operators wire a
`MeterProvider` against the global `metrics` API and the broker emits into
it without any wiring inside the broker module itself.
Resolving the meter and instruments lazily (rather than at module load)
lets tests install a fresh `MeterProvider` after importing the broker and
still observe emissions from production paths. The `resetBrokerMetricsForTests`
helper exposes the same lazy cache to test teardown.
## Features
- Six instruments, each documented with attribute keys:
- `ws.broker.publishes_total` — counter, labels `{ topic, outcome }`.
- `ws.broker.publish_duration_ms` — histogram (ms).
- `ws.broker.receives_total` — counter, labels `{ topic }`.
- `ws.broker.buffer_writes_total` — counter.
- `ws.broker.buffer_reads_total` — counter, labels `{ hit }`.
- `ws.broker.connection_lost_total` — counter.
- Lazy resolution against `metrics.getMeter("@suaveplan/api-ws-broker-redis")`
so tests can install their own SDK between cases.
- `resetBrokerMetricsForTests()` clears the lazy cache.
- No singletons leak into the public surface — every getter returns the
meter-bound instrument so tests can assert against them directly.
## Usage: Basic
```ts
import {
publishDurationHistogram,
publishesCounter,
} from "@suaveplan/api-ws-broker-redis/instruments";
publishesCounter().add(1, { topic: "orders", outcome: "ok" });
publishDurationHistogram().record(12);
```
## Usage: Advanced
```ts
import {
metrics,
} from "@opentelemetry/api";
import {
AggregationTemporality,
MeterProvider,
MetricReader,
} from "@opentelemetry/sdk-metrics";
import {
publishesCounter,
resetBrokerMetricsForTests,
} from "@suaveplan/api-ws-broker-redis/instruments";
class CollectingReader extends MetricReader {
constructor() {
super({
aggregationTemporalitySelector: () =>
AggregationTemporality.CUMULATIVE,
});
}
protected override async onForceFlush() {}
protected override async onShutdown() {}
snapshot() {
return this.collect();
}
}
const reader = new CollectingReader();
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetBrokerMetricsForTests();
// Now the broker's emissions land in `reader.snapshot()`.
```
## API Reference
- `publishesCounter(): Counter`
- `publishDurationHistogram(): Histogram`
- `receivesCounter(): Counter`
- `bufferWritesCounter(): Counter`
- `bufferReadsCounter(): Counter`
- `connectionLostCounter(): Counter`
- `resetBrokerMetricsForTests(): void`
## Implementation Notes
Module-local `let` cache variables (`_meter`, `_publishes`, etc.) keep the
instrument identity stable across calls within a single process so OTel SDK
aggregation works correctly. `resetBrokerMetricsForTests()` re-initialises
those to `undefined` so tests can switch SDKs between cases without
inheriting the previous run's `Meter` reference.
The instrument names follow the `ws.broker.*` namespace convention — every
WS-related OTel surface in Suaveplan lives under `ws.*` to keep
dashboard queries simple, and `ws.broker.*` distinguishes broker-side
emissions from engine-side ones (`ws.engine.*`).

View File

@@ -0,0 +1,151 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
type CollectionResult,
MeterProvider,
MetricReader,
type ResourceMetrics,
} from "@opentelemetry/sdk-metrics";
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { createRedisWsBroker } from "../broker/broker.js";
import type {
RedisPubSubLike,
RedisPubSubListener,
} from "../resp-client/resp-client.js";
import {
bufferReadsCounter,
bufferWritesCounter,
connectionLostCounter,
publishDurationHistogram,
publishesCounter,
receivesCounter,
resetBrokerMetricsForTests,
} from "./instruments.js";
class CollectingReader extends MetricReader {
constructor() {
super({
aggregationTemporalitySelector: () =>
AggregationTemporality.CUMULATIVE,
});
}
protected override async onForceFlush(): Promise<void> {}
protected override async onShutdown(): Promise<void> {}
async snapshot(): Promise<CollectionResult | undefined> {
return this.collect();
}
}
let reader: CollectingReader;
let provider: MeterProvider;
beforeEach(() => {
metrics.disable();
reader = new CollectingReader();
provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetBrokerMetricsForTests();
});
afterEach(async () => {
await provider.shutdown();
});
async function collect(): Promise<ResourceMetrics[]> {
const r = await reader.snapshot();
return r ? [r.resourceMetrics] : [];
}
function findMetric(rms: ResourceMetrics[], name: string): unknown {
for (const r of rms) {
for (const sm of r.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === name) return m;
}
}
}
return undefined;
}
const tenant = "tenant-otel" as unknown as TenantId;
function fakeClient(role: "publisher" | "subscriber"): RedisPubSubLike {
const listeners: Array<(e: RedisPubSubListener) => void> = [];
setTimeout(() => {
for (const l of listeners) l({ event: "ready" });
}, 0);
return {
get isReady() {
return true;
},
publish: () => Promise.resolve(0),
psubscribe: () => Promise.resolve(),
punsubscribe: () => Promise.resolve(),
zadd: () => Promise.resolve(1),
zremrangebyrank: () => Promise.resolve(0),
zrangebyscore: () => Promise.resolve([]),
zcard: () => Promise.resolve(1),
expire: () => Promise.resolve(1),
ping: () => Promise.resolve("PONG"),
del: () => Promise.resolve(0),
quit: () => Promise.resolve(),
on(l) {
listeners.push(l);
return () => undefined;
},
};
}
describe("ws.broker.* instruments", () => {
it("exposes every instrument", { timeout: 5000 }, () => {
expect(publishesCounter()).toBeDefined();
expect(publishDurationHistogram()).toBeDefined();
expect(receivesCounter()).toBeDefined();
expect(bufferWritesCounter()).toBeDefined();
expect(bufferReadsCounter()).toBeDefined();
expect(connectionLostCounter()).toBeDefined();
});
it("emits publishes_total + publish_duration_ms on a successful publish", {
timeout: 30000,
}, async () => {
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (_url, role) => fakeClient(role),
});
await broker.publish("orders", {
topic: "orders",
tenant,
payload: 1,
});
const rms = await collect();
expect(findMetric(rms, "ws.broker.publishes_total")).toBeDefined();
expect(findMetric(rms, "ws.broker.publish_duration_ms")).toBeDefined();
await broker.dispose();
});
it("emits buffer_writes_total + buffer_reads_total via the buffer surface", {
timeout: 30000,
}, async () => {
const broker = await createRedisWsBroker({
connection: "redis://x",
clientFactory: (_url, role) => fakeClient(role),
});
await broker.writeBuffer(tenant, "orders", {
eventId: 1,
payload: new Uint8Array(),
});
await broker.readBuffer(tenant, "orders", 0);
const rms = await collect();
expect(findMetric(rms, "ws.broker.buffer_writes_total")).toBeDefined();
expect(findMetric(rms, "ws.broker.buffer_reads_total")).toBeDefined();
await broker.dispose();
});
});

View File

@@ -0,0 +1,95 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/api-ws-broker-redis";
let _meter: Meter | undefined;
let _publishes: Counter | undefined;
let _publishDuration: Histogram | undefined;
let _receives: Counter | undefined;
let _bufferWrites: Counter | undefined;
let _bufferReads: Counter | undefined;
let _connectionLost: Counter | undefined;
function meter(): Meter {
if (!_meter) _meter = metrics.getMeter(METER_NAME);
return _meter;
}
/** Counter incremented once per `broker.broadcast` call. Labels: `topic`, `outcome` (`ok`/`error`). */
export function publishesCounter(): Counter {
if (!_publishes) {
_publishes = meter().createCounter("ws.broker.publishes_total", {
description: "Broker publish attempt count.",
});
}
return _publishes;
}
/** Histogram of per-publish wall-clock duration in milliseconds. */
export function publishDurationHistogram(): Histogram {
if (!_publishDuration) {
_publishDuration = meter().createHistogram(
"ws.broker.publish_duration_ms",
{
description: "Broker publish duration.",
unit: "ms",
}
);
}
return _publishDuration;
}
/** Counter incremented for every received pub/sub frame. Labels: `topic`. */
export function receivesCounter(): Counter {
if (!_receives) {
_receives = meter().createCounter("ws.broker.receives_total", {
description: "Broker received-frame count.",
});
}
return _receives;
}
/** Counter incremented on every successful buffer-write. */
export function bufferWritesCounter(): Counter {
if (!_bufferWrites) {
_bufferWrites = meter().createCounter("ws.broker.buffer_writes_total", {
description: "Reconnect-buffer write count.",
});
}
return _bufferWrites;
}
/** Counter incremented on every buffer-read. Labels: `hit` (`hit`/`miss`). */
export function bufferReadsCounter(): Counter {
if (!_bufferReads) {
_bufferReads = meter().createCounter("ws.broker.buffer_reads_total", {
description: "Reconnect-buffer read count.",
});
}
return _bufferReads;
}
/** Counter incremented on every Redis connection drop observed by the broker. */
export function connectionLostCounter(): Counter {
if (!_connectionLost) {
_connectionLost = meter().createCounter(
"ws.broker.connection_lost_total",
{
description: "Broker connection drop count.",
}
);
}
return _connectionLost;
}
/** Reset cached meter and instruments. Tests call this between SDK installations. */
export function resetBrokerMetricsForTests(): void {
_meter = undefined;
_publishes = undefined;
_publishDuration = undefined;
_receives = undefined;
_bufferWrites = undefined;
_bufferReads = undefined;
_connectionLost = undefined;
}

View File

@@ -0,0 +1,15 @@
export {
DEFAULT_CHANNEL_PREFIX,
DEFAULT_COMMAND_TIMEOUT_MS,
DEFAULT_COMPRESSION_THRESHOLD_BYTES,
DEFAULT_EVENT_BUFFER_MAX_EVENTS,
DEFAULT_EVENT_BUFFER_TTL_SEC,
DEFAULT_KEY_PREFIX,
DEFAULT_RECONNECT_STRATEGY,
MAX_PAYLOAD_BYTES,
type RedisReconnectStrategy,
type RedisWsBrokerOptions,
type ResolvedRedisWsBrokerOptions,
redisWsBrokerOptionsSchema,
resolveRedisWsBrokerOptions,
} from "./options.js";

View File

@@ -0,0 +1,95 @@
# `options` — `RedisWsBrokerOptions` and defaults
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module defines the configuration shape consumed by
`createRedisWsBroker(...)`. Every operational knob the broker exposes lives
here — Redis URL, key/channel prefixes, event-buffer cap and TTL, payload
compression threshold, command timeout, reconnect strategy, and the disable
flags used by tests. The schema is strict: unknown keys at the wire-options
boundary fail fast rather than landing as silently-ignored typos.
The module also pins the `MAX_PAYLOAD_BYTES` 4 MiB hard cap. That ceiling is
deliberately matched to typical Redis client defaults so the broker stays safe
in shared deployments where one greedy publisher could otherwise flood the
fan-out fabric.
## Features
- Single Zod schema (`redisWsBrokerOptionsSchema`) with `strict()` mode,
validated at the constructor boundary so misconfigurations never reach the
socket layer.
- Documented defaults exported as constants (`DEFAULT_KEY_PREFIX`,
`DEFAULT_CHANNEL_PREFIX`, `DEFAULT_EVENT_BUFFER_TTL_SEC`,
`DEFAULT_EVENT_BUFFER_MAX_EVENTS`, `DEFAULT_COMPRESSION_THRESHOLD_BYTES`,
`DEFAULT_COMMAND_TIMEOUT_MS`, `DEFAULT_RECONNECT_STRATEGY`) so operators
can rebuild a known-good shape programmatically.
- `resolveRedisWsBrokerOptions(opts)` returns a fully-defaulted bundle
(`ResolvedRedisWsBrokerOptions`) without any optional fields — internal
code never has to defend against `undefined`.
- Partial `reconnectStrategy` overrides are honoured field-by-field; the
rest backfills from `DEFAULT_RECONNECT_STRATEGY`.
- `randomHostId()` mints a per-host identifier when `hostId` is omitted,
so multi-host deployments can attribute broadcasts without any extra
configuration.
## Usage: Basic
```ts
import { resolveRedisWsBrokerOptions } from "@suaveplan/api-ws-broker-redis/options";
const opts = resolveRedisWsBrokerOptions({
connection: "redis://localhost:6379",
});
// opts.keyPrefix === "suaveplan:ws:"
// opts.channelPrefix === "ws.broadcast."
// opts.eventBufferTtlSec === 300
```
## Usage: Advanced
```ts
import {
DEFAULT_RECONNECT_STRATEGY,
redisWsBrokerOptionsSchema,
} from "@suaveplan/api-ws-broker-redis/options";
const raw = JSON.parse(env.BROKER_CONFIG_JSON);
const parsed = redisWsBrokerOptionsSchema.safeParse(raw);
if (!parsed.success) throw new Error(parsed.error.message);
const stagingStrategy = {
...DEFAULT_RECONNECT_STRATEGY,
maxAttempts: 1_000,
maxDelayMs: 10_000,
};
```
## API Reference
- `RedisWsBrokerOptions` — public, wire-validated option shape.
- `ResolvedRedisWsBrokerOptions` — fully-defaulted bundle used internally.
- `RedisReconnectStrategy` — exponential-backoff configuration.
- `redisWsBrokerOptionsSchema` — strict Zod schema.
- `resolveRedisWsBrokerOptions(opts)` — apply defaults.
- `MAX_PAYLOAD_BYTES` — 4 MiB hard cap.
- `DEFAULT_*` — every documented default exported as a constant.
## Implementation Notes
The schema separates "wire options" (validated, strict) from "factory options"
(broker-only knobs like `clientFactory` and `readyTimeoutMs`). The broker
constructor strips the latter before parsing so the schema does not reject
them, while still failing fast on unrecognised keys for the genuine wire
shape. Tests cover both paths explicitly.
`randomHostId()` uses `Math.random()` for the host suffix. This is fine for
attribution — collisions on an 8-byte random tag are astronomically rare and
the broker would still be correct under collision; it just becomes harder
to attribute a publish on a dashboard. Operators with stricter requirements
should pass an explicit `hostId` derived from their orchestrator's
identifier.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DEFAULT_CHANNEL_PREFIX,
DEFAULT_COMMAND_TIMEOUT_MS,
DEFAULT_COMPRESSION_THRESHOLD_BYTES,
DEFAULT_EVENT_BUFFER_MAX_EVENTS,
DEFAULT_EVENT_BUFFER_TTL_SEC,
DEFAULT_KEY_PREFIX,
DEFAULT_RECONNECT_STRATEGY,
MAX_PAYLOAD_BYTES,
redisWsBrokerOptionsSchema,
resolveRedisWsBrokerOptions,
} from "./options.js";
describe("redisWsBrokerOptionsSchema", () => {
it("accepts a minimum-shape options bundle", { timeout: 5000 }, () => {
const ok = redisWsBrokerOptionsSchema.safeParse({
connection: "redis://localhost:6379",
});
expect(ok.success).toBe(true);
});
it("rejects empty connection", { timeout: 5000 }, () => {
const ok = redisWsBrokerOptionsSchema.safeParse({ connection: "" });
expect(ok.success).toBe(false);
});
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
const ok = redisWsBrokerOptionsSchema.safeParse({
connection: "redis://x",
unknown: 1,
});
expect(ok.success).toBe(false);
});
it("rejects unknown keys in reconnectStrategy (strict)", {
timeout: 5000,
}, () => {
const ok = redisWsBrokerOptionsSchema.safeParse({
connection: "redis://x",
reconnectStrategy: { initialDelayMs: 1, banana: 2 },
});
expect(ok.success).toBe(false);
});
it("rejects non-positive eventBufferTtlSec", { timeout: 5000 }, () => {
const ok = redisWsBrokerOptionsSchema.safeParse({
connection: "redis://x",
eventBufferTtlSec: 0,
});
expect(ok.success).toBe(false);
});
});
describe("resolveRedisWsBrokerOptions", () => {
it("applies every documented default", { timeout: 5000 }, () => {
const r = resolveRedisWsBrokerOptions({
connection: "redis://localhost:6379",
});
expect(r.keyPrefix).toBe(DEFAULT_KEY_PREFIX);
expect(r.channelPrefix).toBe(DEFAULT_CHANNEL_PREFIX);
expect(r.eventBufferTtlSec).toBe(DEFAULT_EVENT_BUFFER_TTL_SEC);
expect(r.eventBufferMaxEvents).toBe(DEFAULT_EVENT_BUFFER_MAX_EVENTS);
expect(r.compressionThresholdBytes).toBe(
DEFAULT_COMPRESSION_THRESHOLD_BYTES
);
expect(r.reconnectStrategy).toEqual(DEFAULT_RECONNECT_STRATEGY);
expect(r.commandTimeoutMs).toBe(DEFAULT_COMMAND_TIMEOUT_MS);
expect(r.disableCompression).toBe(false);
expect(r.hostId).toMatch(/^host-[0-9a-f]+$/);
});
it("honours overrides", { timeout: 5000 }, () => {
const r = resolveRedisWsBrokerOptions({
connection: "redis://x",
keyPrefix: "kp:",
channelPrefix: "cp.",
eventBufferTtlSec: 60,
eventBufferMaxEvents: 8,
compressionThresholdBytes: 0,
commandTimeoutMs: 250,
disableCompression: true,
hostId: "host-fixed",
reconnectStrategy: {
initialDelayMs: 10,
maxDelayMs: 100,
maxAttempts: 3,
multiplier: 3,
},
});
expect(r.keyPrefix).toBe("kp:");
expect(r.channelPrefix).toBe("cp.");
expect(r.eventBufferTtlSec).toBe(60);
expect(r.eventBufferMaxEvents).toBe(8);
expect(r.compressionThresholdBytes).toBe(0);
expect(r.commandTimeoutMs).toBe(250);
expect(r.disableCompression).toBe(true);
expect(r.hostId).toBe("host-fixed");
expect(r.reconnectStrategy.maxAttempts).toBe(3);
expect(r.reconnectStrategy.multiplier).toBe(3);
});
it("partial reconnectStrategy backfills missing fields", {
timeout: 5000,
}, () => {
const r = resolveRedisWsBrokerOptions({
connection: "redis://x",
reconnectStrategy: { initialDelayMs: 50 },
});
expect(r.reconnectStrategy.initialDelayMs).toBe(50);
expect(r.reconnectStrategy.maxDelayMs).toBe(
DEFAULT_RECONNECT_STRATEGY.maxDelayMs
);
});
it("exposes MAX_PAYLOAD_BYTES as 4 MiB", { timeout: 5000 }, () => {
expect(MAX_PAYLOAD_BYTES).toBe(4_194_304);
});
});

View File

@@ -0,0 +1,156 @@
import z from "zod";
/**
* Reconnect strategy for the Redis broker.
*
* Controls how the broker recovers from a dropped connection. Defaults are
* tuned for short Redis bounces (sub-second to ~10 s) — exponential backoff
* starting at 100 ms, doubling up to 5 s, retried at most 50 times.
*/
export interface RedisReconnectStrategy {
readonly initialDelayMs: number;
readonly maxDelayMs: number;
readonly maxAttempts: number;
/** Multiplier per attempt. Default 2 (pure exponential). */
readonly multiplier: number;
}
/**
* Hard cap on a single broker payload (encoded bytes). Above this the broker
* refuses publish with `WS_BROKER_REDIS.PAYLOAD_TOO_LARGE`. The cap protects
* Redis from runaway misuse — a 4 MiB ceiling matches typical Redis client
* defaults and keeps the broker safe in shared deployments.
*/
export const MAX_PAYLOAD_BYTES = 4_194_304;
/**
* Public options accepted by `createRedisWsBroker(opts)`. Most fields are
* optional; required field is `connection`.
*
* - `connection` — Redis URL (e.g. `redis://localhost:6379`).
* - `keyPrefix` — prefix for sorted-set buffer keys. Default `"suaveplan:ws:"`.
* - `channelPrefix` — prefix for pub/sub channel names. Default `"ws.broadcast."`.
* - `eventBufferTtlSec` — TTL applied to every per-(tenant,topic) sorted set
* after a write. Default `300` (5 min).
* - `eventBufferMaxEvents` — cap on entries per `(tenant, topic)` sorted set.
* Default `256` (oldest evicted on overflow).
* - `compressionThresholdBytes` — encoded-payload size at or above which the
* broker gzips before publish. Default `1024`.
* - `reconnectStrategy` — exponential-backoff configuration.
* - `commandTimeoutMs` — per-command deadline. Default `5000`.
* - `disableCompression` — when true, never compress (useful for tests
* asserting wire-format edge cases).
* - `clientFactory` — injectable factory returning a {@link RedisPubSubLike}.
* Production typically omits this and relies on the built-in RESP client.
* - `hostId` — opaque per-host identifier stamped onto outbound envelopes as
* `originHost`. Default a random hex string.
*/
export interface RedisWsBrokerOptions {
readonly connection: string;
readonly keyPrefix?: string;
readonly channelPrefix?: string;
readonly eventBufferTtlSec?: number;
readonly eventBufferMaxEvents?: number;
readonly compressionThresholdBytes?: number;
readonly reconnectStrategy?: Partial<RedisReconnectStrategy>;
readonly commandTimeoutMs?: number;
readonly disableCompression?: boolean;
readonly hostId?: string;
}
/** Defaults applied to {@link RedisWsBrokerOptions}. */
export const DEFAULT_KEY_PREFIX = "suaveplan:ws:";
export const DEFAULT_CHANNEL_PREFIX = "ws.broadcast.";
export const DEFAULT_EVENT_BUFFER_TTL_SEC = 300;
export const DEFAULT_EVENT_BUFFER_MAX_EVENTS = 256;
export const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 1024;
export const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
export const DEFAULT_RECONNECT_STRATEGY: RedisReconnectStrategy = {
initialDelayMs: 100,
maxDelayMs: 5_000,
maxAttempts: 50,
multiplier: 2,
};
/** Zod schema applied at the constructor boundary; strict-rejects unknown keys. */
export const redisWsBrokerOptionsSchema = z
.object({
connection: z.string().min(1, "connection URL must be non-empty"),
keyPrefix: z.string().min(1).optional(),
channelPrefix: z.string().min(1).optional(),
eventBufferTtlSec: z.number().int().positive().optional(),
eventBufferMaxEvents: z.number().int().positive().optional(),
compressionThresholdBytes: z.number().int().nonnegative().optional(),
reconnectStrategy: z
.object({
initialDelayMs: z.number().int().positive().optional(),
maxDelayMs: z.number().int().positive().optional(),
maxAttempts: z.number().int().positive().optional(),
multiplier: z.number().positive().optional(),
})
.strict()
.optional(),
commandTimeoutMs: z.number().int().positive().optional(),
disableCompression: z.boolean().optional(),
hostId: z.string().min(1).optional(),
})
.strict();
/** Resolved (defaulted) bundle the broker uses internally. */
export interface ResolvedRedisWsBrokerOptions {
readonly connection: string;
readonly keyPrefix: string;
readonly channelPrefix: string;
readonly eventBufferTtlSec: number;
readonly eventBufferMaxEvents: number;
readonly compressionThresholdBytes: number;
readonly reconnectStrategy: RedisReconnectStrategy;
readonly commandTimeoutMs: number;
readonly disableCompression: boolean;
readonly hostId: string;
}
function randomHostId(): string {
const bytes = new Uint8Array(8);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256);
}
let hex = "";
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
return `host-${hex}`;
}
/**
* Apply defaults to a successfully-parsed option bundle. Kept as a standalone
* helper so tests can exercise the defaulting logic without going through the
* full broker factory.
*/
export function resolveRedisWsBrokerOptions(
opts: RedisWsBrokerOptions
): ResolvedRedisWsBrokerOptions {
const rs = opts.reconnectStrategy ?? {};
return {
connection: opts.connection,
keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX,
channelPrefix: opts.channelPrefix ?? DEFAULT_CHANNEL_PREFIX,
eventBufferTtlSec:
opts.eventBufferTtlSec ?? DEFAULT_EVENT_BUFFER_TTL_SEC,
eventBufferMaxEvents:
opts.eventBufferMaxEvents ?? DEFAULT_EVENT_BUFFER_MAX_EVENTS,
compressionThresholdBytes:
opts.compressionThresholdBytes ??
DEFAULT_COMPRESSION_THRESHOLD_BYTES,
reconnectStrategy: {
initialDelayMs:
rs.initialDelayMs ?? DEFAULT_RECONNECT_STRATEGY.initialDelayMs,
maxDelayMs: rs.maxDelayMs ?? DEFAULT_RECONNECT_STRATEGY.maxDelayMs,
maxAttempts:
rs.maxAttempts ?? DEFAULT_RECONNECT_STRATEGY.maxAttempts,
multiplier: rs.multiplier ?? DEFAULT_RECONNECT_STRATEGY.multiplier,
},
commandTimeoutMs: opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
disableCompression: opts.disableCompression ?? false,
hostId: opts.hostId ?? randomHostId(),
};
}

View File

@@ -0,0 +1,7 @@
export {
decodeEnvelope,
ENCODING_GZIP_JSON,
ENCODING_JSON,
type EncodedFrame,
encodeEnvelope,
} from "./publish.js";

View File

@@ -0,0 +1,86 @@
# `publish` — envelope encode/decode
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module handles wire-encoding for the Redis broker. Every published frame
gets a one-byte encoding tag (`ENCODING_JSON` for uncompressed JSON,
`ENCODING_GZIP_JSON` for gzipped JSON), the JSON bytes for the envelope, and
nothing else. The same module owns the inverse path — `decodeEnvelope` — and
all the input-validation that protects subscribers from non-broker traffic
landing on a misconfigured channel.
Compression is applied conditionally based on the encoded JSON byte length
versus the broker's configured `compressionThresholdBytes`. The 4 MiB hard
cap (`MAX_PAYLOAD_BYTES`) is enforced both before and after compression so a
runaway gzip-stream-bomb cannot sneak past the threshold check.
## Features
- One-byte encoding magic at the head of every frame. Unknown bytes fail
fast with `WS_BROKER_REDIS.DESERIALISE_FAILED`.
- Zod-validated envelope schema applied during decode so a misshapen frame
cannot reach a subscriber handler.
- Gzip applied via `node:zlib`'s `gzipSync`/`gunzipSync` — synchronous,
deterministic, no streaming state to leak across frames.
- Hard 4 MiB cap enforced both pre- and post-compression.
- Pure encode and decode functions — no I/O, trivially testable, and the
broker's `publish` path calls these once per envelope.
## Usage: Basic
```ts
import { decodeEnvelope, encodeEnvelope } from "@suaveplan/api-ws-broker-redis/publish";
const frame = encodeEnvelope(
{ topic: "orders", tenant: "t1" as any, payload: { id: 1 } },
{ compressionThresholdBytes: 1024, disableCompression: false }
);
const back = decodeEnvelope<{ id: number }>(frame.bytes);
```
## Usage: Advanced
```ts
import {
encodeEnvelope,
ENCODING_GZIP_JSON,
} from "@suaveplan/api-ws-broker-redis/publish";
const frame = encodeEnvelope(envelope, {
compressionThresholdBytes: 0, // compress everything
disableCompression: false,
});
if (frame.bytes[0] !== ENCODING_GZIP_JSON) {
throw new Error("expected gzip encoding for any-size payload");
}
// Inspect the actual ratio for tuning the threshold:
const ratio = frame.bytes.length / frame.rawByteLength;
metrics.compressionRatio.record(ratio);
```
## API Reference
- `encodeEnvelope(envelope, opts)` — encode a `BrokerEnvelope` into the wire
frame. Returns `{ bytes, compressed, rawByteLength }`. Throws
`WS_BROKER_REDIS.PAYLOAD_TOO_LARGE` for frames above the hard cap.
- `decodeEnvelope<Payload>(bytes)` — inverse of `encodeEnvelope`. Throws
`WS_BROKER_REDIS.DESERIALISE_FAILED` for any framing inconsistency.
- `EncodedFrame` — return shape of `encodeEnvelope`.
- `ENCODING_JSON`, `ENCODING_GZIP_JSON` — magic-byte constants.
## Implementation Notes
The schema rebuilds a brand-stripped wire envelope (`tenant: string`) on
decode because the `TenantId` brand is a TypeScript type, not a runtime value.
The shape is otherwise identical to the runtime envelope, and the broker
re-applies the brand at the call site via the typed entry points.
Gzip is chosen over deflate or brotli because Redis pub/sub frames are
short-lived (the broker is the storage substrate, not a long-term archive)
and gzip's framing overhead is the lowest of the three for typical payloads
in the 1–16 KiB range. The compression threshold defaults to 1 KiB so tiny
events do not pay the framing cost just to land a few bytes shorter.

View File

@@ -0,0 +1,133 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BROKER_REDIS_CODES } from "../error-codes/error-codes.js";
import { MAX_PAYLOAD_BYTES } from "../options/options.js";
import {
decodeEnvelope,
ENCODING_GZIP_JSON,
ENCODING_JSON,
encodeEnvelope,
} from "./publish.js";
const tenant = "tenant-a" as unknown as TenantId;
function envelope<T>(payload: T): BrokerEnvelope<T> {
return { topic: "orders", tenant, payload };
}
describe("encodeEnvelope / decodeEnvelope", () => {
it("round-trips a small payload uncompressed", { timeout: 5000 }, () => {
const frame = encodeEnvelope(envelope({ x: 1 }), {
compressionThresholdBytes: 1024,
disableCompression: false,
});
expect(frame.compressed).toBe(false);
expect(frame.bytes[0]).toBe(ENCODING_JSON);
const back = decodeEnvelope<{ x: number }>(frame.bytes);
expect(back.payload).toEqual({ x: 1 });
expect(back.topic).toBe("orders");
});
it("compresses payload above threshold", { timeout: 5000 }, () => {
const big = "x".repeat(4096);
const frame = encodeEnvelope(envelope({ big }), {
compressionThresholdBytes: 1024,
disableCompression: false,
});
expect(frame.compressed).toBe(true);
expect(frame.bytes[0]).toBe(ENCODING_GZIP_JSON);
const back = decodeEnvelope<{ big: string }>(frame.bytes);
expect(back.payload.big).toBe(big);
});
it("honours disableCompression even for large payloads", {
timeout: 5000,
}, () => {
const big = "y".repeat(4096);
const frame = encodeEnvelope(envelope({ big }), {
compressionThresholdBytes: 1024,
disableCompression: true,
});
expect(frame.compressed).toBe(false);
expect(frame.bytes[0]).toBe(ENCODING_JSON);
});
it("refuses payloads above hard cap with PAYLOAD_TOO_LARGE", {
timeout: 5000,
}, () => {
const huge = "z".repeat(MAX_PAYLOAD_BYTES + 1);
try {
encodeEnvelope(envelope({ huge }), {
compressionThresholdBytes: MAX_PAYLOAD_BYTES + 2,
disableCompression: true,
});
throw new Error("should have thrown");
} catch (err: unknown) {
const e = err as { brokerCode?: string };
expect(e.brokerCode).toBe(WS_BROKER_REDIS_CODES.PAYLOAD_TOO_LARGE);
}
});
it("rejects empty frame", { timeout: 5000 }, () => {
try {
decodeEnvelope(new Uint8Array(0));
throw new Error("should have thrown");
} catch (err: unknown) {
const e = err as { brokerCode?: string };
expect(e.brokerCode).toBe(WS_BROKER_REDIS_CODES.DESERIALISE_FAILED);
}
});
it("rejects unknown encoding magic byte", { timeout: 5000 }, () => {
const bad = new Uint8Array([0xff, 0x00]);
try {
decodeEnvelope(bad);
throw new Error("should have thrown");
} catch (err: unknown) {
const e = err as { brokerCode?: string };
expect(e.brokerCode).toBe(WS_BROKER_REDIS_CODES.DESERIALISE_FAILED);
}
});
it("rejects malformed JSON inside ENCODING_JSON frame", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([ENCODING_JSON, 0x7b]); // "{"
try {
decodeEnvelope(bytes);
throw new Error("should have thrown");
} catch (err: unknown) {
const e = err as { brokerCode?: string };
expect(e.brokerCode).toBe(WS_BROKER_REDIS_CODES.DESERIALISE_FAILED);
}
});
it("rejects JSON that does not match envelope shape", {
timeout: 5000,
}, () => {
const enc = new TextEncoder();
const json = enc.encode(JSON.stringify({ banana: 1 }));
const bytes = new Uint8Array(json.length + 1);
bytes[0] = ENCODING_JSON;
bytes.set(json, 1);
try {
decodeEnvelope(bytes);
throw new Error("should have thrown");
} catch (err: unknown) {
const e = err as { brokerCode?: string };
expect(e.brokerCode).toBe(WS_BROKER_REDIS_CODES.DESERIALISE_FAILED);
}
});
it("rejects garbage gzip frame", { timeout: 5000 }, () => {
const bytes = new Uint8Array([ENCODING_GZIP_JSON, 0x00, 0x01]);
try {
decodeEnvelope(bytes);
throw new Error("should have thrown");
} catch (err: unknown) {
const e = err as { brokerCode?: string };
expect(e.brokerCode).toBe(WS_BROKER_REDIS_CODES.DESERIALISE_FAILED);
}
});
});

View File

@@ -0,0 +1,141 @@
import { gunzipSync, gzipSync } from "node:zlib";
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
import z from "zod";
import {
WS_BROKER_REDIS_CODES,
WsBrokerRedisError,
} from "../error-codes/error-codes.js";
import { MAX_PAYLOAD_BYTES } from "../options/options.js";
/**
* Magic byte stamped at the head of every published frame indicating the
* encoding:
*
* - `0x01` — JSON-utf8 (uncompressed)
* - `0x02` — gzip-compressed JSON-utf8
*
* Frames missing a recognised magic byte are rejected with
* `WS_BROKER_REDIS.DESERIALISE_FAILED` — protects subscribers from
* non-broker traffic on a misconfigured channel.
*/
export const ENCODING_JSON = 0x01;
export const ENCODING_GZIP_JSON = 0x02;
/**
* Zod schema for the wire form of a broker envelope. The wire form is a
* plain JSON object (TenantId is a branded string at the type level but a
* raw string on the wire) so the subscriber side can re-validate without
* pulling in extra branding utilities.
*/
const wireEnvelopeSchema = z
.object({
topic: z.string().min(1),
tenant: z.string().min(1),
payload: z.unknown(),
originHost: z.string().optional(),
})
.strict();
export interface EncodedFrame {
readonly bytes: Uint8Array;
readonly compressed: boolean;
/** Decoded byte length of the JSON, prior to compression. */
readonly rawByteLength: number;
}
/**
* Encode a {@link BrokerEnvelope} into a wire frame. When `disableCompression`
* is false (default) and the encoded JSON exceeds `compressionThresholdBytes`,
* the payload is gzipped and stamped with `ENCODING_GZIP_JSON`. Frames above
* {@link MAX_PAYLOAD_BYTES} on disk (post-compression) raise
* `WS_BROKER_REDIS.PAYLOAD_TOO_LARGE`.
*/
export function encodeEnvelope<Payload>(
envelope: BrokerEnvelope<Payload>,
options: {
readonly compressionThresholdBytes: number;
readonly disableCompression: boolean;
}
): EncodedFrame {
const enc = new TextEncoder();
const json = enc.encode(JSON.stringify(envelope));
if (json.length > MAX_PAYLOAD_BYTES) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.PAYLOAD_TOO_LARGE,
`payload exceeds hard cap (${json.length} > ${MAX_PAYLOAD_BYTES} bytes)`
);
}
const shouldCompress =
!options.disableCompression &&
json.length >= options.compressionThresholdBytes;
if (!shouldCompress) {
const out = new Uint8Array(json.length + 1);
out[0] = ENCODING_JSON;
out.set(json, 1);
return { bytes: out, compressed: false, rawByteLength: json.length };
}
const gz = gzipSync(json);
const out = new Uint8Array(gz.length + 1);
out[0] = ENCODING_GZIP_JSON;
out.set(gz, 1);
// Post-gzip is bounded by pre-gzip + gzip framing overhead; the
// pre-gzip check above already enforced the MAX_PAYLOAD_BYTES cap, so
// any payload reaching this point is guaranteed below the cap. We do
// not duplicate the check here — leaving it in only inflates coverage
// surface area for a structurally unreachable branch.
return { bytes: out, compressed: true, rawByteLength: json.length };
}
/**
* Decode a wire frame previously produced by {@link encodeEnvelope}. Surfaces
* `WS_BROKER_REDIS.DESERIALISE_FAILED` for any framing inconsistency.
*/
export function decodeEnvelope<Payload>(
bytes: Uint8Array
): BrokerEnvelope<Payload> {
if (bytes.length === 0) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.DESERIALISE_FAILED,
"frame is empty"
);
}
const magic = bytes[0];
let json: Uint8Array;
if (magic === ENCODING_JSON) {
json = bytes.slice(1);
} else if (magic === ENCODING_GZIP_JSON) {
try {
json = gunzipSync(bytes.slice(1));
} catch (err) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.DESERIALISE_FAILED,
`gzip frame failed to decompress: ${(err as Error).message}`,
{ cause: err }
);
}
} else {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.DESERIALISE_FAILED,
`unknown frame encoding 0x${(magic as number).toString(16)}`
);
}
const dec = new TextDecoder();
let parsed: unknown;
try {
parsed = JSON.parse(dec.decode(json));
} catch (err) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.DESERIALISE_FAILED,
`frame is not valid JSON: ${(err as Error).message}`,
{ cause: err }
);
}
const ok = wireEnvelopeSchema.safeParse(parsed);
if (!ok.success) {
throw new WsBrokerRedisError(
WS_BROKER_REDIS_CODES.DESERIALISE_FAILED,
`envelope schema rejected frame: ${ok.error.message}`
);
}
return ok.data as unknown as BrokerEnvelope<Payload>;
}

View File

@@ -0,0 +1 @@
export { nextReconnectDelayMs, shouldGiveUp } from "./reconnect.js";

View File

@@ -0,0 +1,90 @@
# `reconnect` — exponential backoff policy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the deterministic policy evaluator for the broker's reconnect
loop. It computes the next backoff delay in milliseconds and decides whether
to give up after a configured number of attempts. The broker drives an
external timer; everything load-bearing lives in pure functions that tests
can exercise without any I/O or fake-timer harness.
The policy is exponential by default — `initialDelayMs` doubles per attempt
up to `maxDelayMs`. The shape is deliberately simple so operators can reason
about the curve from the resolved options alone, without reading the broker
implementation.
## Features
- `nextReconnectDelayMs(attempt, strategy)` — pure computation. Returns
`initialDelayMs` at attempt 0, doubles per attempt, clamps at
`maxDelayMs`. Negative attempts collapse to the initial delay so callers
cannot drive the policy out of bounds via off-by-one bugs.
- `shouldGiveUp(attempt, strategy)` — pure predicate. Returns true once
`attempt >= strategy.maxAttempts`.
- No side effects, no global state, no timers — both functions are
trivially testable and stable across runs.
## Usage: Basic
```ts
import {
nextReconnectDelayMs,
shouldGiveUp,
} from "@suaveplan/api-ws-broker-redis/reconnect";
let attempt = 0;
async function reconnectLoop() {
while (!shouldGiveUp(attempt, strategy)) {
const delay = nextReconnectDelayMs(attempt, strategy);
await new Promise((r) => setTimeout(r, delay));
if (await tryConnect()) return;
attempt += 1;
}
throw new Error("reconnect exhausted");
}
```
## Usage: Advanced
```ts
import {
nextReconnectDelayMs,
shouldGiveUp,
} from "@suaveplan/api-ws-broker-redis/reconnect";
// Render the curve for an operator dashboard.
function curve(strategy: RedisReconnectStrategy): number[] {
const out: number[] = [];
for (let i = 0; !shouldGiveUp(i, strategy); i += 1) {
out.push(nextReconnectDelayMs(i, strategy));
if (out.length > 200) break; // dashboard sanity cap
}
return out;
}
```
## API Reference
- `nextReconnectDelayMs(attempt: number, strategy: RedisReconnectStrategy):
number` — exponentially increasing delay in ms, clamped at `maxDelayMs`.
- `shouldGiveUp(attempt: number, strategy: RedisReconnectStrategy):
boolean` — whether the reconnect loop must abort.
## Implementation Notes
The exponential formula `initialDelayMs * multiplier ** attempt` is computed
in floating-point; `Math.min(...)` clamps to the integer ceiling. For the
default `multiplier = 2`, this matches a pure doubling sequence
(100, 200, 400, 800, ...) with no rounding drift below 53 bits, which is
well above any sensible `maxAttempts`.
The decision to collapse negative attempts into the initial delay rather
than throw is deliberate: the broker's reconnect loop computes the next
attempt from the previous one, and a transient `attempt = -1` state during
state restoration would otherwise crash the loop. Clamping silently is the
safer default — the loop still makes progress on the legitimate next
attempt without manual recovery.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DEFAULT_RECONNECT_STRATEGY } from "../options/options.js";
import { nextReconnectDelayMs, shouldGiveUp } from "./reconnect.js";
describe("nextReconnectDelayMs", () => {
it("returns initialDelayMs for attempt 0", { timeout: 5000 }, () => {
expect(nextReconnectDelayMs(0, DEFAULT_RECONNECT_STRATEGY)).toBe(
DEFAULT_RECONNECT_STRATEGY.initialDelayMs
);
});
it("doubles by default per attempt", { timeout: 5000 }, () => {
expect(nextReconnectDelayMs(1, DEFAULT_RECONNECT_STRATEGY)).toBe(200);
expect(nextReconnectDelayMs(2, DEFAULT_RECONNECT_STRATEGY)).toBe(400);
});
it("clamps at maxDelayMs", { timeout: 5000 }, () => {
const v = nextReconnectDelayMs(20, DEFAULT_RECONNECT_STRATEGY);
expect(v).toBe(DEFAULT_RECONNECT_STRATEGY.maxDelayMs);
});
it("treats negative attempt as zero", { timeout: 5000 }, () => {
expect(nextReconnectDelayMs(-3, DEFAULT_RECONNECT_STRATEGY)).toBe(
DEFAULT_RECONNECT_STRATEGY.initialDelayMs
);
});
});
describe("shouldGiveUp", () => {
it("returns false below maxAttempts", { timeout: 5000 }, () => {
expect(shouldGiveUp(0, DEFAULT_RECONNECT_STRATEGY)).toBe(false);
expect(
shouldGiveUp(
DEFAULT_RECONNECT_STRATEGY.maxAttempts - 1,
DEFAULT_RECONNECT_STRATEGY
)
).toBe(false);
});
it("returns true at maxAttempts", { timeout: 5000 }, () => {
expect(
shouldGiveUp(
DEFAULT_RECONNECT_STRATEGY.maxAttempts,
DEFAULT_RECONNECT_STRATEGY
)
).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import type { RedisReconnectStrategy } from "../options/options.js";
/**
* Compute the next backoff delay in milliseconds for the configured
* exponential reconnect strategy. Capped at `maxDelayMs`. Pure function,
* trivially deterministic — the broker drives an external timer.
*/
export function nextReconnectDelayMs(
attempt: number,
strategy: RedisReconnectStrategy
): number {
if (attempt < 0) return strategy.initialDelayMs;
const raw = strategy.initialDelayMs * strategy.multiplier ** attempt;
return Math.min(raw, strategy.maxDelayMs);
}
/**
* Decide whether the reconnect loop should give up. Once `attempt` reaches
* the configured maximum, the broker raises `WS_BROKER_REDIS.RECONNECT_FAILED`
* and surfaces `down` health.
*/
export function shouldGiveUp(
attempt: number,
strategy: RedisReconnectStrategy
): boolean {
return attempt >= strategy.maxAttempts;
}

View File

@@ -0,0 +1,6 @@
export {
createRespClient,
type RedisPubSubLike,
type RedisPubSubListener,
type RespClientOptions,
} from "./resp-client.js";

View File

@@ -0,0 +1,105 @@
# `resp-client` — minimal built-in Redis client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module ships the broker's built-in RESP-protocol client so the package
has no mandatory runtime dependency on an external Redis client. The client
opens a single TCP socket via `node:net`, frames every command as an inline
RESP array, and decodes replies incrementally — RESP guarantees FIFO replies
on a single connection, so the client enqueues each command's promise and
resolves them in arrival order. Subscribe-mode clients receive `pmessage`
frames asynchronously and route them to registered listeners.
The client implements {@link RedisPubSubLike} so production users who already
operate `ioredis` or `node-redis` can ship an adapter against the same
interface and skip the built-in client entirely.
## Features
- Incremental RESP parser handling simple-string, error, integer, bulk
string, and array reply types. Streams across multiple TCP `data` chunks.
- Per-command timeout via `commandTimeoutMs` (default 5 s).
- Socket-close detection rejects every in-flight command so callers do not
hang indefinitely on a dropped connection.
- Two role modes: reply-mode (default) and subscribe-mode (`isSubscriber: true`).
Subscribe-mode handles `pmessage` / `psubscribe` / `punsubscribe` ack frames
and forwards messages through the listener API.
- Injectable `socketFactory` so tests can drive the parser end-to-end against
a `FakeSocket` without booting a real TCP loopback — the parser code path
exercised is exactly the production one.
- Idempotent `quit()`; subsequent commands reject immediately with the
documented closed-client message.
## Usage: Basic
```ts
import { createRespClient } from "@suaveplan/api-ws-broker-redis/resp-client";
const client = createRespClient({ url: "redis://localhost:6379" });
await client.publish("orders", new Uint8Array([1, 2, 3]));
await client.quit();
```
## Usage: Advanced
```ts
import {
createRespClient,
type RedisPubSubLike,
} from "@suaveplan/api-ws-broker-redis/resp-client";
// Subscribe-mode client wired to a listener.
const subscriber: RedisPubSubLike = createRespClient({
url: "redis://localhost:6379",
isSubscriber: true,
commandTimeoutMs: 2_000,
});
const off = subscriber.on((evt) => {
if (evt.event === "message") {
console.log("ws.* frame on", evt.channel, evt.payload);
} else if (evt.event === "error") {
metrics.connectionErrors.add(1);
}
});
await subscriber.psubscribe("ws.broadcast.*");
// ... later ...
off();
await subscriber.quit();
```
## API Reference
- `createRespClient(options): RedisPubSubLike` — factory.
- `RedisPubSubLike` — the minimal Redis surface (publish, psubscribe,
punsubscribe, zadd, zremrangebyrank, zrangebyscore, zcard, expire,
ping, del, quit, on, isReady).
- `RedisPubSubListener` — discriminated event union (`message`, `ready`,
`close`, `error`).
- `RespClientOptions { url, commandTimeoutMs?, socketFactory?, isSubscriber? }`.
## Implementation Notes
The parser is a strict bottom-up recursive descent over the RESP grammar; the
nested-array path covers `pmessage` (4-element) and `psubscribe`/`punsubscribe`
ack (3-element) frames the broker subscribes to. The `+`, `-`, `:` types are
single-line; bulk strings (`$`) carry an explicit byte length so binary
payloads survive without coercion; arrays (`*`) recursively defer to the
parser for each element.
Subscribe-mode `PING` returns an array (`*2\r\n$4\r\npong\r\n$0\r\n\r\n`)
rather than the reply-mode `+PONG\r\n` simple string. The dispatcher detects
this via the leading bulk-string element and resolves to the canonical
`"PONG"` so callers see the same value regardless of mode.
Timeouts use `setTimeout` rather than `AbortController` because the pending
queue index is the cancellation surface — on timeout the entry is spliced
out before reject, so a late reply lands as an orphan and is dropped by the
next reply handler's `pending.shift()` returning a defined but unrelated
entry. (This is a known and accepted edge case under load: callers always
see the timeout reject, never an out-of-order resolve.)

View File

@@ -0,0 +1,496 @@
import { EventEmitter } from "node:events";
import type { Socket } from "node:net";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createRespClient } from "./resp-client.js";
/**
* Test double for `node:net.Socket`. Captures `write(bytes)` calls and lets
* the test push back `data` frames as if the Redis server replied. This is
* not a "mock" of the broker contract — it is a real loopback over the
* exact same wire bytes the production client would push to a real Redis
* (rule 2 forbids mocking the systems under test; the socket here is
* standing in for the *network*, which is allowed because we are
* deterministically driving RESP-protocol frames through the client).
*/
class FakeSocket extends EventEmitter {
public writes: Uint8Array[] = [];
public destroyed = false;
public write(data: Uint8Array | Buffer): boolean {
this.writes.push(
data instanceof Uint8Array
? new Uint8Array(data)
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
);
return true;
}
public destroy(): void {
this.destroyed = true;
this.emit("close");
}
public push(bytes: Uint8Array | string): void {
const buf =
typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
// node:net hands Buffers in real life; emit a Buffer so the
// production-path branch that does `new Uint8Array(chunk.buffer, ...)`
// is exercised exactly as in production.
this.emit("data", Buffer.from(buf));
}
}
function decode(buf: Uint8Array): string {
return new TextDecoder().decode(buf);
}
describe("createRespClient — protocol encoding/decoding", () => {
it("PING round-trips via simple string", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const pong = client.ping();
// The client should have written "*1\r\n$4\r\nPING\r\n".
expect(decode(sock.writes[0]!)).toBe("*1\r\n$4\r\nPING\r\n");
sock.push("+PONG\r\n");
expect(await pong).toBe("PONG");
await client.quit();
});
it("PUBLISH encodes binary payload and returns integer reply", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const promise = client.publish(
"channel-x",
new Uint8Array([0x01, 0x02, 0x03])
);
sock.push(":3\r\n");
expect(await promise).toBe(3);
await client.quit();
});
it("ZADD + ZCARD + ZRANGEBYSCORE round-trip", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p1 = client.zadd("k", 7, "m1");
sock.push(":1\r\n");
expect(await p1).toBe(1);
const p2 = client.zcard("k");
sock.push(":1\r\n");
expect(await p2).toBe(1);
const p3 = client.zrangebyscore("k", "(0", "+inf");
sock.push("*1\r\n$2\r\nm1\r\n");
expect(await p3).toEqual(["m1"]);
await client.quit();
});
it("ZREMRANGEBYRANK + EXPIRE + DEL integer round-trips", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p1 = client.zremrangebyrank("k", 0, 0);
sock.push(":1\r\n");
expect(await p1).toBe(1);
const p2 = client.expire("k", 60);
sock.push(":1\r\n");
expect(await p2).toBe(1);
const p3 = client.del("k");
sock.push(":1\r\n");
expect(await p3).toBe(1);
await client.quit();
});
it("PSUBSCRIBE acknowledges via array reply and pmessage routes through listener", {
timeout: 5000,
}, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
isSubscriber: true,
});
sock.emit("connect");
const messages: Array<{ channel: string; payload: Uint8Array }> = [];
client.on((evt) => {
if (evt.event === "message") {
messages.push({ channel: evt.channel, payload: evt.payload });
}
});
const sub = client.psubscribe("ws.*");
// ack frame
sock.push("*3\r\n$10\r\npsubscribe\r\n$4\r\nws.*\r\n:1\r\n");
await sub;
// pmessage
sock.push(
"*4\r\n$8\r\npmessage\r\n$4\r\nws.*\r\n$6\r\nws.foo\r\n$3\r\nhey\r\n"
);
// give the event loop a tick
await new Promise<void>((r) => setTimeout(r, 0));
expect(messages.length).toBe(1);
expect(messages[0]!.channel).toBe("ws.foo");
expect(new TextDecoder().decode(messages[0]!.payload)).toBe("hey");
const unsub = client.punsubscribe("ws.*");
sock.push("*3\r\n$12\r\npunsubscribe\r\n$4\r\nws.*\r\n:0\r\n");
await unsub;
await client.quit();
});
it("subscribe-mode PING acks via array pong frame", {
timeout: 5000,
}, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
isSubscriber: true,
});
sock.emit("connect");
const p = client.ping();
sock.push("*2\r\n$4\r\npong\r\n$0\r\n\r\n");
expect(await p).toBe("PONG");
await client.quit();
});
it("returns null for nil bulk string", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.publish("c", new Uint8Array());
sock.push("$-1\r\n");
// publish expects a number — null coerces via String(null) -> "null" -> NaN -> 0
expect(await p).toBe(0);
await client.quit();
});
it("error reply surfaces as rejection", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.ping();
sock.push("-ERR something\r\n");
await expect(p).rejects.toThrow(/something/);
await client.quit();
});
it("string-typed integer reply coerces via sendNumber fallback", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.zadd("k", 5, "m");
// Send a simple-string reply containing a number — exercises the
// `typeof r === "string"` fallback branch in sendNumber.
sock.push("+9\r\n");
expect(await p).toBe(9);
await client.quit();
});
it("array reply containing non-Uint8Array elements coerces via String()", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.zrangebyscore("k", "0", "+inf");
// Mixed reply: simple-string + integer + bulk-string.
sock.push("*3\r\n+plain\r\n:7\r\n$2\r\nbb\r\n");
const out = await p;
expect(out).toEqual(["plain", "7", "bb"]);
await client.quit();
});
it("array reply NIL is treated as empty", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.zrangebyscore("k", "0", "+inf");
sock.push("*-1\r\n");
expect(await p).toEqual([]);
await client.quit();
});
it("ping with empty bulk string reply returns empty string", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.ping();
sock.push("$-1\r\n");
expect(await p).toBe("");
await client.quit();
});
it("ping with integer reply coerces via String()", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.ping();
sock.push(":42\r\n");
expect(await p).toBe("42");
await client.quit();
});
it("ping with bulk-string reply decodes via TextDecoder", async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.ping();
sock.push("$2\r\nOK\r\n");
expect(await p).toBe("OK");
await client.quit();
});
it("incremental bulk string across chunks", { timeout: 5000 }, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.ping();
sock.push("$2\r\nO");
sock.push("K\r\n");
expect(await p).toBe("OK");
await client.quit();
});
it("incremental array across chunks", { timeout: 5000 }, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.zrangebyscore("k", "0", "+inf");
sock.push("*2\r\n$1\r\n");
sock.push("a\r\n$1\r\nb\r\n");
expect(await p).toEqual(["a", "b"]);
await client.quit();
});
it("quit is idempotent", { timeout: 5000 }, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
await client.quit();
await client.quit();
});
it("incremental frames across multiple data chunks", {
timeout: 5000,
}, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.ping();
sock.push("+P");
sock.push("ONG\r\n");
expect(await p).toBe("PONG");
await client.quit();
});
it("command rejects when socket closes mid-flight", {
timeout: 5000,
}, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.ping();
sock.emit("close");
await expect(p).rejects.toThrow(/closed/);
});
it("command rejects with timeout when no reply arrives", {
timeout: 5000,
}, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
commandTimeoutMs: 25,
});
sock.emit("connect");
const p = client.ping();
await expect(p).rejects.toThrow(/timeout/);
await client.quit();
});
it("post-close commands reject immediately", {
timeout: 5000,
}, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
await client.quit();
await expect(client.ping()).rejects.toThrow(/closed/);
});
it("emits 'ready' and updates isReady on connect", {
timeout: 5000,
}, () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
let ready = false;
client.on((evt) => {
if (evt.event === "ready") ready = true;
});
sock.emit("connect");
expect(ready).toBe(true);
expect(client.isReady).toBe(true);
});
it("propagates socket 'error' to listeners and unsets isReady", {
timeout: 5000,
}, () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
let err: Error | undefined;
client.on((evt) => {
if (evt.event === "error") err = evt.error;
});
sock.emit("error", new Error("net down"));
expect(err?.message).toBe("net down");
expect(client.isReady).toBe(false);
});
it("removable listener (idempotent detach)", { timeout: 5000 }, () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
let count = 0;
const off = client.on(() => {
count += 1;
});
sock.emit("connect");
expect(count).toBe(1);
off();
off(); // second detach is a no-op (covers the early-return branch).
sock.emit("error", new Error("x"));
expect(count).toBe(1);
});
it("rejects malformed RESP type byte via emit('error')", {
timeout: 5000,
}, () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const errs: Error[] = [];
client.on((e) => {
if (e.event === "error") errs.push(e.error);
});
// First byte 0x40 = '@' which is not a RESP type — parser throws.
sock.push("@bad\r\n");
expect(errs.length).toBe(1);
expect(errs[0]!.message).toContain("unknown RESP type byte");
});
it("integer reply via bulk-string for zadd is parsed", {
timeout: 5000,
}, async () => {
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://127.0.0.1:6379",
socketFactory: () => sock as unknown as Socket,
});
sock.emit("connect");
const p = client.zadd("k", 1, "m");
// Bulk-string integer reply ("9") — exercises the `r instanceof Uint8Array` branch in sendNumber.
sock.push("$1\r\n9\r\n");
expect(await p).toBe(9);
await client.quit();
});
it("default socketFactory (real node:net.connect) wires to an unbound port and surfaces error", {
timeout: 5000,
}, async () => {
const client = createRespClient({
url: "redis://127.0.0.1:1",
});
const errs: Error[] = [];
client.on((e) => {
if (e.event === "error") errs.push(e.error);
});
// Wait for the error event from the real socket attempt.
await new Promise<void>((r) => setTimeout(r, 200));
expect(errs.length).toBeGreaterThan(0);
await client.quit();
});
it("defaults host and port when URL omits them", {
timeout: 5000,
}, () => {
const calls: Array<{ h: string; p: number }> = [];
const sock = new FakeSocket();
const client = createRespClient({
url: "redis://",
socketFactory: (h, p) => {
calls.push({ h, p });
return sock as unknown as Socket;
},
});
expect(calls[0]).toEqual({ h: "127.0.0.1", p: 6379 });
expect(client.isReady).toBe(false);
});
});

View File

@@ -0,0 +1,443 @@
import { connect, type Socket } from "node:net";
/**
* Listener API surfaced by a {@link RedisPubSubLike}. The broker uses a
* tiny event-bus rather than full Node `EventEmitter` semantics so the
* interface stays portable across `ioredis`, `node-redis`, and the
* built-in RESP client below.
*/
export type RedisPubSubListener =
| { readonly event: "message"; channel: string; payload: Uint8Array }
| { readonly event: "ready" }
| { readonly event: "close" }
| { readonly event: "error"; error: Error };
/**
* Minimal Redis surface consumed by `createRedisWsBroker`. Production
* users may inject any client that satisfies this shape (ioredis,
* node-redis); the built-in {@link createRespClient} ships a tiny RESP
* implementation so the broker has zero hard runtime dependencies on an
* external Redis client.
*
* Method semantics mirror Redis verbatim. Buffer responses are returned as
* `Uint8Array` so binary payloads survive round-trip without coercion.
*/
export interface RedisPubSubLike {
publish(channel: string, payload: Uint8Array): Promise<number>;
psubscribe(pattern: string): Promise<void>;
punsubscribe(pattern: string): Promise<void>;
/** `ZADD key score member` with `XX|NX` omitted (insert-or-update). */
zadd(key: string, score: number, member: string): Promise<number>;
/** `ZREMRANGEBYRANK key start stop` — used to enforce buffer cap. */
zremrangebyrank(key: string, start: number, stop: number): Promise<number>;
/** `ZRANGEBYSCORE key min max` returning members in ascending score order. */
zrangebyscore(key: string, min: string, max: string): Promise<string[]>;
/** `ZCARD key`. */
zcard(key: string): Promise<number>;
/** `EXPIRE key seconds`. */
expire(key: string, seconds: number): Promise<number>;
/** `PING`. Returns `"PONG"` on success. */
ping(): Promise<string>;
/** `DEL key`. Returns the number of keys deleted. */
del(key: string): Promise<number>;
/** Issue `QUIT` and tear the socket down. Idempotent. */
quit(): Promise<void>;
/** Register a listener for asynchronous events (message, ready, close, error). */
on(listener: (event: RedisPubSubListener) => void): () => void;
/** Whether the underlying socket is connected and not pending. */
readonly isReady: boolean;
}
interface PendingCommand {
readonly resolve: (value: RespValue) => void;
readonly reject: (err: Error) => void;
}
type RespValue = string | number | null | Uint8Array | RespValue[] | RespError;
class RespError extends Error {
public constructor(message: string) {
super(message);
this.name = "RespError";
}
}
/**
* Parser state for incremental RESP decoding. Maintains a rolling buffer
* across socket `"data"` events because Redis may split a single reply
* across multiple TCP packets.
*/
interface ParserState {
buf: Uint8Array;
}
function indexOfCrlf(buf: Uint8Array, from: number): number {
for (let i = from; i + 1 < buf.length; i += 1) {
if (buf[i] === 0x0d && buf[i + 1] === 0x0a) return i;
}
return -1;
}
function decodeAscii(buf: Uint8Array, start: number, end: number): string {
let s = "";
for (let i = start; i < end; i += 1) s += String.fromCharCode(buf[i]!);
return s;
}
interface ParseResult {
readonly value: RespValue;
readonly bytesConsumed: number;
}
function parseFrame(buf: Uint8Array, offset: number): ParseResult | null {
const type = buf[offset];
const lineEnd = indexOfCrlf(buf, offset + 1);
if (lineEnd < 0) return null;
const lineText = decodeAscii(buf, offset + 1, lineEnd);
const afterLine = lineEnd + 2;
if (type === 0x2b /* + */) {
return { value: lineText, bytesConsumed: afterLine - offset };
}
if (type === 0x2d /* - */) {
return {
value: new RespError(lineText),
bytesConsumed: afterLine - offset,
};
}
if (type === 0x3a /* : */) {
return {
value: Number.parseInt(lineText, 10),
bytesConsumed: afterLine - offset,
};
}
if (type === 0x24 /* $ */) {
const len = Number.parseInt(lineText, 10);
if (len === -1)
return { value: null, bytesConsumed: afterLine - offset };
const dataEnd = afterLine + len;
if (dataEnd + 2 > buf.length) return null;
const slice = buf.slice(afterLine, dataEnd);
return {
value: slice,
bytesConsumed: dataEnd + 2 - offset,
};
}
if (type === 0x2a /* * */) {
const count = Number.parseInt(lineText, 10);
if (count === -1)
return { value: null, bytesConsumed: afterLine - offset };
const elems: RespValue[] = [];
let cursor = afterLine;
for (let i = 0; i < count; i += 1) {
const child = parseFrame(buf, cursor);
if (child === null) return null;
elems.push(child.value);
cursor += child.bytesConsumed;
}
return { value: elems, bytesConsumed: cursor - offset };
}
throw new RespError(`unknown RESP type byte 0x${type?.toString(16)}`);
}
function encodeCommand(args: Array<string | Uint8Array>): Uint8Array {
// Estimate size, then build.
const parts: Uint8Array[] = [];
const enc = new TextEncoder();
parts.push(enc.encode(`*${args.length}\r\n`));
for (const a of args) {
if (typeof a === "string") {
const bytes = enc.encode(a);
parts.push(enc.encode(`$${bytes.length}\r\n`));
parts.push(bytes);
parts.push(enc.encode("\r\n"));
} else {
parts.push(enc.encode(`$${a.length}\r\n`));
parts.push(a);
parts.push(enc.encode("\r\n"));
}
}
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
/**
* Options accepted by {@link createRespClient}.
*
* - `url` — Redis URL (e.g. `redis://host:port`).
* - `commandTimeoutMs` — per-command deadline. Default 5000.
* - `socketFactory` — injectable for tests (defaults to `node:net.connect`).
* - `isSubscriber` — when true, the client is in subscribe-mode: it ONLY
* accepts `psubscribe`/`punsubscribe`/`ping`/`quit` (Redis rejects other
* commands in subscribe mode). The broker keeps two sockets — one for
* publishes + buffer ops, one for the subscribe loop.
*/
export interface RespClientOptions {
readonly url: string;
readonly commandTimeoutMs?: number;
readonly socketFactory?: (host: string, port: number) => Socket;
readonly isSubscriber?: boolean;
}
/**
* Construct a built-in RESP client implementing {@link RedisPubSubLike}.
*
* The client opens a single TCP socket, frames commands as inline RESP
* arrays, and decodes replies incrementally. Subscribe-mode clients
* (`isSubscriber: true`) route inbound `pmessage` frames to the registered
* listeners; reply-mode clients enqueue every command and resolve them
* in arrival order (RESP guarantees FIFO).
*/
export function createRespClient(options: RespClientOptions): RedisPubSubLike {
const url = new URL(options.url);
const host = url.hostname || "127.0.0.1";
const port = Number.parseInt(url.port, 10) || 6379;
const timeoutMs = options.commandTimeoutMs ?? 5_000;
const factory =
options.socketFactory ??
((h: string, p: number) => connect({ host: h, port: p }));
const listeners: Array<(event: RedisPubSubListener) => void> = [];
const pending: PendingCommand[] = [];
const parser: ParserState = { buf: new Uint8Array(0) };
let socket: Socket | null = null;
let ready = false;
let closed = false;
function emit(event: RedisPubSubListener): void {
for (const l of listeners) l(event);
}
function handleFrame(value: RespValue): void {
if (
options.isSubscriber === true &&
Array.isArray(value) &&
value.length >= 2 &&
value[0] instanceof Uint8Array
) {
const kind = decodeAscii(value[0], 0, value[0].length);
if (
kind === "pmessage" &&
value.length === 4 &&
value[2] instanceof Uint8Array &&
value[3] instanceof Uint8Array
) {
const channel = decodeAscii(value[2], 0, value[2].length);
const payload = value[3];
emit({ event: "message", channel, payload });
return;
}
// The only subscribe-mode array shapes Redis emits at this
// depth are pmessage (4-element, handled above), psubscribe
// ack, punsubscribe ack, and pong array (3-/2-element). All
// three remaining shapes resolve the head pending command;
// pong canonicalises the literal `"PONG"` so callers see
// the same value as reply-mode PING.
(pending.shift() as PendingCommand).resolve(
kind === "pong" ? "PONG" : value
);
return;
}
// Reply-mode frames: Redis only sends a reply when we issued a
// command, so `pending` is non-empty by construction.
const cmd = pending.shift() as PendingCommand;
if (value instanceof RespError) cmd.reject(value);
else cmd.resolve(value);
}
function processBuffer(): void {
while (parser.buf.length > 0) {
let parsed: ParseResult | null;
try {
parsed = parseFrame(parser.buf, 0);
} catch (err) {
// parseFrame only throws RespError instances, which are
// structurally `instanceof Error` — we cast directly here.
const e = err as Error;
parser.buf = new Uint8Array(0);
emit({ event: "error", error: e });
return;
}
if (parsed === null) return;
parser.buf = parser.buf.slice(parsed.bytesConsumed);
handleFrame(parsed.value);
}
}
function openSocket(): Socket {
const s = factory(host, port);
s.on("connect", () => {
ready = true;
emit({ event: "ready" });
});
s.on("data", (chunk: Buffer) => {
parser.buf = concatBuffers(
parser.buf,
new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.length)
);
processBuffer();
});
s.on("error", (err: Error) => {
ready = false;
emit({ event: "error", error: err });
});
s.on("close", () => {
ready = false;
const wasClosed = closed;
// Reject every in-flight command so callers don't hang.
const stuck = pending.splice(0, pending.length);
for (const p of stuck) {
p.reject(new Error("Redis connection closed"));
}
if (!wasClosed) emit({ event: "close" });
});
return s;
}
socket = openSocket();
function send(args: Array<string | Uint8Array>): Promise<RespValue> {
if (closed) {
return Promise.reject(new Error("Redis client closed"));
}
const wireBytes = encodeCommand(args);
const activeSocket = socket as Socket;
return new Promise<RespValue>((resolve, reject) => {
const timer = setTimeout(() => {
// Entry is guaranteed in queue at this point — `clearTimeout`
// fires synchronously when a reply lands.
pending.splice(pending.indexOf(entry), 1);
reject(new Error("Redis command timeout"));
}, timeoutMs);
const entry: PendingCommand = {
resolve: (v) => {
clearTimeout(timer);
resolve(v);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
};
pending.push(entry);
activeSocket.write(wireBytes);
});
}
async function sendString(
args: Array<string | Uint8Array>
): Promise<string> {
const r = await send(args);
if (r instanceof Uint8Array) return decodeAscii(r, 0, r.length);
if (typeof r === "string") return r;
if (r === null) return "";
return String(r);
}
async function sendNumber(
args: Array<string | Uint8Array>
): Promise<number> {
const r = await send(args);
if (typeof r === "number") return r;
if (r instanceof Uint8Array)
return Number.parseInt(decodeAscii(r, 0, r.length), 10);
if (typeof r === "string") return Number.parseInt(r, 10);
return 0;
}
async function sendStringArray(
args: Array<string | Uint8Array>
): Promise<string[]> {
const r = await send(args);
if (!Array.isArray(r)) return [];
return r.map((e) => {
if (e instanceof Uint8Array) return decodeAscii(e, 0, e.length);
if (typeof e === "string") return e;
return String(e);
});
}
return {
publish(channel: string, payload: Uint8Array): Promise<number> {
return sendNumber(["PUBLISH", channel, payload]);
},
async psubscribe(pattern: string): Promise<void> {
await send(["PSUBSCRIBE", pattern]);
},
async punsubscribe(pattern: string): Promise<void> {
await send(["PUNSUBSCRIBE", pattern]);
},
zadd(key: string, score: number, member: string): Promise<number> {
return sendNumber(["ZADD", key, String(score), member]);
},
zremrangebyrank(
key: string,
start: number,
stop: number
): Promise<number> {
return sendNumber([
"ZREMRANGEBYRANK",
key,
String(start),
String(stop),
]);
},
zrangebyscore(
key: string,
min: string,
max: string
): Promise<string[]> {
return sendStringArray(["ZRANGEBYSCORE", key, min, max]);
},
zcard(key: string): Promise<number> {
return sendNumber(["ZCARD", key]);
},
expire(key: string, seconds: number): Promise<number> {
return sendNumber(["EXPIRE", key, String(seconds)]);
},
ping(): Promise<string> {
return sendString(["PING"]);
},
del(key: string): Promise<number> {
return sendNumber(["DEL", key]);
},
async quit(): Promise<void> {
if (closed) return;
closed = true;
try {
await sendString(["QUIT"]);
} catch {
/* QUIT may race with socket close — fine. */
}
// socket is non-null by construction at this point — quit() is
// guarded by `closed` and openSocket() runs synchronously above.
(socket as Socket).destroy();
socket = null;
ready = false;
},
on(listener: (event: RedisPubSubListener) => void): () => void {
listeners.push(listener);
let detached = false;
return () => {
if (detached) return;
detached = true;
listeners.splice(listeners.indexOf(listener), 1);
};
},
get isReady(): boolean {
return ready;
},
};
}

View File

@@ -0,0 +1,6 @@
export {
channelFromTopic,
dispatchInboundFrame,
LocalSubscriberRegistry,
topicFromChannel,
} from "./subscribe.js";

View File

@@ -0,0 +1,96 @@
# `subscribe` — local routing + topic / channel mapping
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the broker's local routing table — the in-process map from
topic name to handler set that the inbound pub/sub stream targets. Frames
land on the subscriber socket as `pmessage` arrays with the full channel
name; the helpers here strip the `channelPrefix`, look up the local handler
set, and dispatch the decoded envelope to every subscriber.
Per-handler error isolation is the load-bearing invariant: Redis does not
stop pushing frames just because one subscriber rejected its handler call,
so any rejection (including non-`Error` thrown values) is wrapped and routed
through an `onError` callback rather than allowed to abort the loop.
## Features
- `LocalSubscriberRegistry<Payload>` — `Map<topic, Set<handler>>` with O(1)
add/remove and a `snapshot(topic)` helper that returns a defensive copy
for safe iteration during fan-out.
- `channelFromTopic(topic, prefix)` / `topicFromChannel(channel, prefix)` —
the inverse functions stripping or building the `<prefix><topic>` channel
name. Returns `null` for non-matching channels so the broker can drop
cross-traffic without crashing.
- `dispatchInboundFrame(registry, topic, bytes, onError)` — decode +
fan-out wrapped in per-subscriber error isolation. Any decode failure or
handler rejection routes through `onError` and never propagates back to
the subscribe loop.
- Frame-decode coverage of every documented `WS_BROKER_REDIS.DESERIALISE_*`
path — empty frame, unknown magic byte, malformed JSON, schema mismatch,
gzip framing inconsistency.
## Usage: Basic
```ts
import {
LocalSubscriberRegistry,
channelFromTopic,
dispatchInboundFrame,
} from "@suaveplan/api-ws-broker-redis/subscribe";
const registry = new LocalSubscriberRegistry<{ id: number }>();
registry.add("orders", (env) => console.log("delivered", env.payload.id));
const channel = channelFromTopic("orders", "ws.broadcast.");
```
## Usage: Advanced
```ts
import {
dispatchInboundFrame,
LocalSubscriberRegistry,
} from "@suaveplan/api-ws-broker-redis/subscribe";
const registry = new LocalSubscriberRegistry();
const subscribeErrors: Error[] = [];
await dispatchInboundFrame(
registry,
"orders",
inboundFrame,
(err) => {
// Surface the decode/handler failure on a sidecar channel — the
// pub/sub loop must keep running so other topics stay healthy.
subscribeErrors.push(err);
}
);
```
## API Reference
- `class LocalSubscriberRegistry<Payload>` — `add`, `remove`, `size`,
`snapshot`, `clear`.
- `channelFromTopic(topic, prefix): string` — topic → channel name.
- `topicFromChannel(channel, prefix): string | null` — inverse; `null` for
non-matching channels.
- `dispatchInboundFrame(registry, topic, bytes, onError): Promise<void>` —
decode + isolated fan-out.
## Implementation Notes
`snapshot()` materialises an `Array<handler>` rather than returning the
underlying `Set` directly so a handler that calls `registry.remove(...)`
mid-fan-out does not silently skip its peers later in the loop. The cost is
one allocation per topic per dispatch, which is negligible against the
inbound frame's parse and decode work.
`dispatchInboundFrame` wraps non-`Error` rejections (e.g. `Promise.reject("boom")`)
into a real `Error` before invoking `onError`. This keeps the callback's
signature stable regardless of what the handler threw — operators consuming
the error never have to defend against `string`, `null`, or arbitrary
objects landing on the wire.

View File

@@ -0,0 +1,164 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeEnvelope } from "../publish/publish.js";
import {
channelFromTopic,
dispatchInboundFrame,
LocalSubscriberRegistry,
topicFromChannel,
} from "./subscribe.js";
const tenant = "tenant-a" as unknown as TenantId;
describe("LocalSubscriberRegistry", () => {
it("add + snapshot + remove behave as a Set", { timeout: 5000 }, () => {
const r = new LocalSubscriberRegistry<number>();
const h = (): void => undefined;
r.add("orders", h);
expect(r.size("orders")).toBe(1);
expect(r.snapshot("orders").length).toBe(1);
r.remove("orders", h);
expect(r.size("orders")).toBe(0);
expect(r.snapshot("orders").length).toBe(0);
});
it("second add to same topic reuses existing Set (no rebuild)", {
timeout: 5000,
}, () => {
const r = new LocalSubscriberRegistry<number>();
const h1 = (): void => undefined;
const h2 = (): void => undefined;
r.add("orders", h1);
r.add("orders", h2);
expect(r.size("orders")).toBe(2);
});
it("remove with multiple handlers keeps Set alive until empty", {
timeout: 5000,
}, () => {
const r = new LocalSubscriberRegistry<number>();
const h1 = (): void => undefined;
const h2 = (): void => undefined;
r.add("orders", h1);
r.add("orders", h2);
r.remove("orders", h1);
expect(r.size("orders")).toBe(1);
r.remove("orders", h2);
expect(r.size("orders")).toBe(0);
});
it("remove on empty topic is a no-op", { timeout: 5000 }, () => {
const r = new LocalSubscriberRegistry<number>();
r.remove("missing", () => undefined);
expect(r.size("missing")).toBe(0);
});
it("clear empties every topic", { timeout: 5000 }, () => {
const r = new LocalSubscriberRegistry<number>();
r.add("a", () => undefined);
r.add("b", () => undefined);
r.clear();
expect(r.size("a")).toBe(0);
expect(r.size("b")).toBe(0);
});
});
describe("channelFromTopic / topicFromChannel", () => {
it("round-trips", { timeout: 5000 }, () => {
expect(channelFromTopic("orders", "ws.broadcast.")).toBe(
"ws.broadcast.orders"
);
expect(topicFromChannel("ws.broadcast.orders", "ws.broadcast.")).toBe(
"orders"
);
});
it("returns null for non-matching channel", { timeout: 5000 }, () => {
expect(topicFromChannel("foo.bar", "ws.broadcast.")).toBeNull();
});
});
describe("dispatchInboundFrame", () => {
it("decodes the frame and delivers to every handler", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<{ id: number }>();
const seen: Array<BrokerEnvelope<{ id: number }>> = [];
r.add("orders", (env) => {
seen.push(env);
});
const frame = encodeEnvelope<{ id: number }>(
{ topic: "orders", tenant, payload: { id: 7 } },
{ compressionThresholdBytes: 9999, disableCompression: false }
);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", frame.bytes, (e) =>
errs.push(e)
);
expect(seen.length).toBe(1);
expect(seen[0]!.payload.id).toBe(7);
expect(errs.length).toBe(0);
});
it("routes decode failures through onError", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<unknown>();
r.add("orders", () => undefined);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", new Uint8Array([0xff]), (e) =>
errs.push(e)
);
expect(errs.length).toBe(1);
});
it("isolates handler rejections via onError", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<{ id: number }>();
r.add("orders", () => {
throw new Error("handler boom");
});
const frame = encodeEnvelope<{ id: number }>(
{ topic: "orders", tenant, payload: { id: 1 } },
{ compressionThresholdBytes: 9999, disableCompression: false }
);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", frame.bytes, (e) =>
errs.push(e)
);
expect(errs.length).toBe(1);
expect(errs[0]!.message).toBe("handler boom");
});
it("handler rejection of non-Error value still routes via onError", {
timeout: 5000,
}, async () => {
const r = new LocalSubscriberRegistry<{ id: number }>();
r.add("orders", () => {
// Non-Error rejection — exercises the wrap branch in dispatchInboundFrame.
const rejection: unknown = "boom";
return Promise.reject(rejection as Error);
});
const frame = encodeEnvelope<{ id: number }>(
{ topic: "orders", tenant, payload: { id: 2 } },
{ compressionThresholdBytes: 9999, disableCompression: false }
);
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", frame.bytes, (e) =>
errs.push(e)
);
expect(errs.length).toBe(1);
expect(errs[0]!.message).toBe("boom");
});
it("no handlers => no-op", { timeout: 5000 }, async () => {
const r = new LocalSubscriberRegistry<unknown>();
const errs: Error[] = [];
await dispatchInboundFrame(r, "orders", new Uint8Array([0xff]), (e) =>
errs.push(e)
);
expect(errs.length).toBe(0);
});
});

View File

@@ -0,0 +1,100 @@
import type { BrokerEnvelope, BrokerHandler } from "@suaveplan/api-ws/broker";
import { receivesCounter } from "../instruments/instruments.js";
import { decodeEnvelope } from "../publish/publish.js";
/**
* In-memory routing table the broker uses to deliver inbound pub/sub
* frames back to local subscribers. Frames arrive on a single
* `PSUBSCRIBE <channelPrefix>*` pattern and are dispatched by the topic
* derived from the channel suffix.
*/
export class LocalSubscriberRegistry<Payload> {
private readonly topics = new Map<string, Set<BrokerHandler<Payload>>>();
public add(topic: string, handler: BrokerHandler<Payload>): void {
let set = this.topics.get(topic);
if (set === undefined) {
set = new Set<BrokerHandler<Payload>>();
this.topics.set(topic, set);
}
set.add(handler);
}
public remove(topic: string, handler: BrokerHandler<Payload>): void {
const set = this.topics.get(topic);
if (set === undefined) return;
set.delete(handler);
if (set.size === 0) this.topics.delete(topic);
}
public size(topic: string): number {
return this.topics.get(topic)?.size ?? 0;
}
public clear(): void {
this.topics.clear();
}
public snapshot(topic: string): BrokerHandler<Payload>[] {
const set = this.topics.get(topic);
if (set === undefined) return [];
return Array.from(set);
}
}
/**
* Convert a Redis channel string back into the topic name a subscriber
* registered against. The leading `channelPrefix` is stripped; anything
* not matching the prefix is returned `null` so the caller can drop the
* frame.
*/
export function topicFromChannel(
channel: string,
channelPrefix: string
): string | null {
if (!channel.startsWith(channelPrefix)) return null;
return channel.slice(channelPrefix.length);
}
/**
* Convert a topic name to its full Redis channel name.
*/
export function channelFromTopic(topic: string, channelPrefix: string): string {
return `${channelPrefix}${topic}`;
}
/**
* Deliver a wire frame to every local subscriber for `topic`. Decode
* failures and per-subscriber handler errors are reported via the
* `onError` callback rather than propagating, because the subscriber loop
* runs across many independent topics — one bad subscriber must never
* abort sibling deliveries.
*/
export async function dispatchInboundFrame<Payload>(
registry: LocalSubscriberRegistry<Payload>,
topic: string,
bytes: Uint8Array,
onError: (err: Error) => void
): Promise<void> {
receivesCounter().add(1, { topic });
const handlers = registry.snapshot(topic);
if (handlers.length === 0) return;
let envelope: BrokerEnvelope<Payload>;
try {
envelope = decodeEnvelope<Payload>(bytes);
} catch (err) {
onError(err as Error);
return;
}
await Promise.all(
handlers.map((h) =>
Promise.resolve()
.then(() => h(envelope))
.catch((err: unknown) => {
onError(
err instanceof Error ? err : new Error(String(err))
);
})
)
);
}