Initial Commit
This commit is contained in:
162
packages/api-stack/api/api-ws-broker-redis/README.md
Normal file
162
packages/api-stack/api/api-ws-broker-redis/README.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# @suaveplan/api-ws-broker-redis - Redis pub/sub broker for @suaveplan/api-ws — cross-host WebSocket and SSE fan-out. Publishes BrokerEnvelopes via Redis Pub/Sub channels, maintains per-(tenant,topic) reconnect event-buffers in sorted-sets with cap + TTL, gzip-compresses payloads above a configurable threshold, exposes a healthz probe + auto-reconnect, and emits the standard ws.broker.* OTel surface. Ships a minimal built-in RESP client so integration tests can drive a real Redis container without an external client dependency; accepts any pluggable RedisPubSubLike (e.g. ioredis or node-redis) in production.
|
||||
|
||||
Cross-host Redis pub/sub broker for `@suaveplan/api-ws` and `@suaveplan/api-sse`.
|
||||
|
||||
Implements the `WsBroker` contract from `@suaveplan/api-ws` against a real
|
||||
Redis instance: every `broker.broadcast(topic, envelope)` is published to a
|
||||
shared Redis channel pattern (default `ws.broadcast.*`), and every host
|
||||
subscribed to that pattern delivers the broadcast to its locally-connected
|
||||
subscribers via the local `ConnectionRegistry`. Connections are NOT migrated
|
||||
across hosts — the broker just makes broadcasts visible everywhere.
|
||||
|
||||
The same broker serves the reconnect event-buffer required by `api-ws-bun`
|
||||
(WebSocket) and `api-sse-bun` (SSE): a per-`(tenant, topic)` Redis sorted-set
|
||||
storing the most-recent `eventBufferMaxEvents` events with TTL
|
||||
`eventBufferTtlSec`, queryable by `Last-Event-Id` for `WS` reconnect or
|
||||
`Last-Event-Id` header for SSE resume.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [broker](./src/broker/broker.md) — This module is the orchestrator for `@suaveplan/api-ws-broker-redis`. It wires two Redis clients (one in publish mode,…
|
||||
- [broker](./src/broker/broker.md) — This module is the orchestrator for `@suaveplan/api-ws-broker-redis`. It wires two Redis clients (one in publish mode,…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module defines the stable error-code registry for `@suaveplan/api-ws-broker-redis`. Every failure surface the brok…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module defines the stable error-code registry for `@suaveplan/api-ws-broker-redis`. Every failure surface the brok…
|
||||
- [event-buffer](./src/event-buffer/event-buffer.md) — This module backs the broker's reconnect event buffer. Each `(tenant, topic)` pair maps to one Redis sorted-set keyed b…
|
||||
- [event-buffer](./src/event-buffer/event-buffer.md) — This module backs the broker's reconnect event buffer. Each `(tenant, topic)` pair maps to one Redis sorted-set keyed b…
|
||||
- [health](./src/health/health.md) — This module is the pure, side-effect-free evaluator for the broker's health status. It inspects the two underlying RESP…
|
||||
- [health](./src/health/health.md) — This module is the pure, side-effect-free evaluator for the broker's health status. It inspects the two underlying RESP…
|
||||
- [instruments](./src/instruments/instruments.md) — This module owns the OpenTelemetry instrument surface for the Redis broker. Six instruments cover every documented oper…
|
||||
- [instruments](./src/instruments/instruments.md) — This module owns the OpenTelemetry instrument surface for the Redis broker. Six instruments cover every documented oper…
|
||||
- [options](./src/options/options.md) — This module defines the configuration shape consumed by `createRedisWsBroker(...)`. Every operational knob the broker e…
|
||||
- [options](./src/options/options.md) — This module defines the configuration shape consumed by `createRedisWsBroker(...)`. Every operational knob the broker e…
|
||||
- [publish](./src/publish/publish.md) — This module handles wire-encoding for the Redis broker. Every published frame gets a one-byte encoding tag (`ENCODING_J…
|
||||
- [publish](./src/publish/publish.md) — This module handles wire-encoding for the Redis broker. Every published frame gets a one-byte encoding tag (`ENCODING_J…
|
||||
- [reconnect](./src/reconnect/reconnect.md) — This module is the deterministic policy evaluator for the broker's reconnect loop. It computes the next backoff delay i…
|
||||
- [reconnect](./src/reconnect/reconnect.md) — This module is the deterministic policy evaluator for the broker's reconnect loop. It computes the next backoff delay i…
|
||||
- [resp-client](./src/resp-client/resp-client.md) — This module ships the broker's built-in RESP-protocol client so the package has no mandatory runtime dependency on an e…
|
||||
- [resp-client](./src/resp-client/resp-client.md) — This module ships the broker's built-in RESP-protocol client so the package has no mandatory runtime dependency on an e…
|
||||
- [subscribe](./src/subscribe/subscribe.md) — This module owns the broker's local routing table — the in-process map from topic name to handler set that the inbound…
|
||||
- [subscribe](./src/subscribe/subscribe.md) — This module owns the broker's local routing table — the in-process map from topic name to handler set that the inbound…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What ships
|
||||
|
||||
| Subpath | Module | Doc |
|
||||
|---|---|---|
|
||||
| `./broker` | `createRedisWsBroker(opts)` factory | [`src/broker/broker.md`](src/broker/broker.md) |
|
||||
| `./error-codes` | `WS_BROKER_REDIS_CODES` + `WsBrokerRedisError` | [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) |
|
||||
| `./options` | option schema + defaults | [`src/options/options.md`](src/options/options.md) |
|
||||
| `./resp-client` | built-in minimal RESP client (`createRespClient`) | [`src/resp-client/resp-client.md`](src/resp-client/resp-client.md) |
|
||||
|
||||
## Surface
|
||||
|
||||
- `createRedisWsBroker(opts)` — async factory returning a `RedisWsBroker`
|
||||
(the `WsBroker` contract plus `writeBuffer`, `readBuffer`, `healthz`, and
|
||||
`isDisposed`).
|
||||
- `RedisWsBrokerOptions` — Zod-validated strict option shape. Required:
|
||||
`connection`. Optional: `keyPrefix`, `channelPrefix`, `eventBufferTtlSec`,
|
||||
`eventBufferMaxEvents`, `compressionThresholdBytes`, `reconnectStrategy`,
|
||||
`commandTimeoutMs`, `disableCompression`, `hostId`.
|
||||
- `WS_BROKER_REDIS_CODES` — closed set of 10 stable error codes
|
||||
(`CONNECTION_LOST`, `PUBLISH_FAILED`, `SUBSCRIBE_FAILED`,
|
||||
`BUFFER_WRITE_FAILED`, `BUFFER_READ_FAILED`, `PAYLOAD_TOO_LARGE`,
|
||||
`DESERIALISE_FAILED`, `CONFIG_INVALID`, `RECONNECT_FAILED`, `TIMEOUT`).
|
||||
- `createRespClient(opts)` — minimal built-in RESP client implementing
|
||||
`RedisPubSubLike`. Use this when you do not already have an `ioredis` or
|
||||
`node-redis` instance in your runtime.
|
||||
- `RedisPubSubLike` — pluggable client interface; pass any conforming
|
||||
implementation via `clientFactory` to use `ioredis` / `node-redis` /
|
||||
another adapter.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { createRedisWsBroker } from "@suaveplan/api-ws-broker-redis";
|
||||
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();
|
||||
```
|
||||
|
||||
## Operational notes
|
||||
|
||||
### Key-prefix conventions
|
||||
|
||||
The broker namespaces every Redis key under `<keyPrefix>` (default
|
||||
`suaveplan:ws:`). Within that namespace it owns:
|
||||
|
||||
- `<keyPrefix>buffer:<tenant>:<topic>` — sorted-set holding the last
|
||||
`eventBufferMaxEvents` entries with scores keyed on `eventId`. Member
|
||||
format is `<eventId>|<base64-payload>`.
|
||||
|
||||
Pub/sub channels are namespaced under `<channelPrefix>` (default
|
||||
`ws.broadcast.`): `<channelPrefix><topic>`.
|
||||
|
||||
Two `<keyPrefix>` choices must be in lock-step across every host replica
|
||||
that participates in the same fan-out fabric — otherwise broadcasts will
|
||||
land on different channel names and never be delivered.
|
||||
|
||||
### Redis memory sizing
|
||||
|
||||
The peak Redis memory consumed by a single broker is bounded by:
|
||||
|
||||
```text
|
||||
hosts * tenants * topics * eventBufferMaxEvents * average_event_size
|
||||
```
|
||||
|
||||
with TTL `eventBufferTtlSec` ensuring abandoned topics expire on their own.
|
||||
For default settings (cap 256, TTL 5 min, ~1 KiB events) that is
|
||||
~256 KiB per active `(tenant, topic)`. A 10k-active-topic deployment
|
||||
budgets ~2.5 GiB of Redis memory for buffers alone, before pub/sub
|
||||
traffic. Tune the cap and TTL down for memory-constrained deployments.
|
||||
|
||||
### Multi-host topology
|
||||
|
||||
Every host eagerly `PSUBSCRIBE`s `<channelPrefix>*` at construction time
|
||||
so the first publish is immediately visible to peers. Adding a host is
|
||||
purely additive — no configuration change to existing hosts is required;
|
||||
the new host comes up, subscribes to the pattern, and starts delivering
|
||||
broadcasts to its locally-connected subscribers.
|
||||
|
||||
### Compression
|
||||
|
||||
Payloads above `compressionThresholdBytes` (default 1 KiB) are
|
||||
gzipped before publish. The wire format carries a one-byte encoding
|
||||
magic so subscribers can decode either form transparently. The 4 MiB
|
||||
hard cap (`MAX_PAYLOAD_BYTES`) refuses runaway payloads with
|
||||
`WS_BROKER_REDIS.PAYLOAD_TOO_LARGE`.
|
||||
|
||||
### Pluggable clients
|
||||
|
||||
The default broker opens two sockets via the built-in RESP client
|
||||
(`./resp-client`). Pass `clientFactory: (url, role) => myAdapter(url, role)`
|
||||
to swap in an `ioredis` or `node-redis` adapter — the adapter must satisfy
|
||||
the {@link RedisPubSubLike} interface.
|
||||
|
||||
## Quality bars
|
||||
|
||||
- 100% line / branch / function / statement coverage (Istanbul).
|
||||
- 100% docs coverage — every implementation `.ts` has a sibling `.md` with
|
||||
the required headings.
|
||||
- Zero direct imports of `vitest` / `bun:test` — all test files import
|
||||
from `@suaveplan/testing/runner`.
|
||||
- Stable, namespaced error codes (`WS_BROKER_REDIS.*`).
|
||||
- Real-Redis integration tests via `@suaveplan/testing-integration-redis`
|
||||
with the testcontainers-backed `RedisFixture` (gracefully skipped when
|
||||
Docker is unavailable).
|
||||
|
||||
88
packages/api-stack/api/api-ws-broker-redis/package.json
Normal file
88
packages/api-stack/api/api-ws-broker-redis/package.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "@suaveplan/api-ws-broker-redis",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Redis pub/sub broker for @suaveplan/api-ws — cross-host WebSocket and SSE fan-out. Publishes BrokerEnvelopes via Redis Pub/Sub channels, maintains per-(tenant,topic) reconnect event-buffers in sorted-sets with cap + TTL, gzip-compresses payloads above a configurable threshold, exposes a healthz probe + auto-reconnect, and emits the standard ws.broker.* OTel surface. Ships a minimal built-in RESP client so integration tests can drive a real Redis container without an external client dependency; accepts any pluggable RedisPubSubLike (e.g. ioredis or node-redis) in production.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./broker": {
|
||||
"import": {
|
||||
"types": "./dist/broker/broker.d.ts",
|
||||
"default": "./dist/broker/broker.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./options": {
|
||||
"import": {
|
||||
"types": "./dist/options/options.d.ts",
|
||||
"default": "./dist/options/options.js"
|
||||
}
|
||||
},
|
||||
"./resp-client": {
|
||||
"import": {
|
||||
"types": "./dist/resp-client/resp-client.d.ts",
|
||||
"default": "./dist/resp-client/resp-client.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/api-ws": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "catalog:observability",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@suaveplan/testing-integration-redis": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"testcontainers": "catalog:containers",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
130
packages/api-stack/api/api-ws-broker-redis/src/broker/broker.md
Normal file
130
packages/api-stack/api/api-ws-broker-redis/src/broker/broker.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
327
packages/api-stack/api/api-ws-broker-redis/src/broker/broker.ts
Normal file
327
packages/api-stack/api/api-ws-broker-redis/src/broker/broker.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type {
|
||||
BrokerEnvelope,
|
||||
BrokerHandler,
|
||||
BrokerSubscription,
|
||||
WsBroker,
|
||||
} from "@suaveplan/api-ws/broker";
|
||||
import {
|
||||
WS_BROKER_REDIS_CODES,
|
||||
WsBrokerRedisError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type BufferedEvent,
|
||||
readBufferedEvents,
|
||||
writeBufferedEvent,
|
||||
} from "../event-buffer/event-buffer.js";
|
||||
import {
|
||||
type BrokerHealthSnapshot,
|
||||
evaluateBrokerHealth,
|
||||
} from "../health/health.js";
|
||||
import {
|
||||
connectionLostCounter,
|
||||
publishDurationHistogram,
|
||||
publishesCounter,
|
||||
} from "../instruments/instruments.js";
|
||||
import {
|
||||
type RedisWsBrokerOptions,
|
||||
type ResolvedRedisWsBrokerOptions,
|
||||
redisWsBrokerOptionsSchema,
|
||||
resolveRedisWsBrokerOptions,
|
||||
} from "../options/options.js";
|
||||
import { encodeEnvelope } from "../publish/publish.js";
|
||||
import {
|
||||
createRespClient,
|
||||
type RedisPubSubLike,
|
||||
} from "../resp-client/resp-client.js";
|
||||
import {
|
||||
channelFromTopic,
|
||||
dispatchInboundFrame,
|
||||
LocalSubscriberRegistry,
|
||||
topicFromChannel,
|
||||
} from "../subscribe/subscribe.js";
|
||||
|
||||
/**
|
||||
* Concrete shape returned by {@link createRedisWsBroker}. Extends the
|
||||
* abstract `WsBroker` contract with broker-specific introspection used
|
||||
* by the host's readiness endpoint and by tests.
|
||||
*/
|
||||
export interface RedisWsBroker<Payload = unknown> extends WsBroker<Payload> {
|
||||
publish(topic: string, envelope: BrokerEnvelope<Payload>): Promise<void>;
|
||||
subscribe(
|
||||
topic: string,
|
||||
handler: BrokerHandler<Payload>
|
||||
): Promise<BrokerSubscription>;
|
||||
dispose(): Promise<void>;
|
||||
/** Append-to-buffer surface consumed by `api-ws-bun` + `api-sse-bun`. */
|
||||
writeBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
event: BufferedEvent
|
||||
): Promise<void>;
|
||||
/** Replay-from-buffer surface consumed by `api-ws-bun` + `api-sse-bun`. */
|
||||
readBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
sinceEventId: number
|
||||
): Promise<readonly BufferedEvent[]>;
|
||||
/** Health probe surface consumed by `host.readiness()`. */
|
||||
healthz(): BrokerHealthSnapshot;
|
||||
/** Disposed flag — set after `dispose()` resolves. */
|
||||
isDisposed(): boolean;
|
||||
/** Resolved (defaulted) options the broker is using. */
|
||||
readonly options: ResolvedRedisWsBrokerOptions;
|
||||
}
|
||||
|
||||
/** Factory contract for swapping in an alternative client (e.g. ioredis adapter). */
|
||||
export type RedisClientFactory = (
|
||||
url: string,
|
||||
role: "publisher" | "subscriber"
|
||||
) => RedisPubSubLike;
|
||||
|
||||
/** Extended options accepted by {@link createRedisWsBroker} (vs. the wire schema). */
|
||||
export interface CreateRedisWsBrokerOptions extends RedisWsBrokerOptions {
|
||||
/** Injectable client factory; defaults to the built-in RESP client. */
|
||||
readonly clientFactory?: RedisClientFactory;
|
||||
/** Wait-for-ready timeout when constructing the broker. Default 5_000 ms. */
|
||||
readonly readyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 5_000;
|
||||
|
||||
function waitForReady(
|
||||
client: RedisPubSubLike,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
if (client.isReady) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off();
|
||||
reject(
|
||||
new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONNECTION_LOST,
|
||||
`Redis client did not become ready within ${timeoutMs}ms`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
const off = client.on((evt) => {
|
||||
if (evt.event === "ready") {
|
||||
clearTimeout(timer);
|
||||
off();
|
||||
resolve();
|
||||
} else if (evt.event === "error" || evt.event === "close") {
|
||||
clearTimeout(timer);
|
||||
off();
|
||||
reject(
|
||||
new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONNECTION_LOST,
|
||||
`Redis connect failed: ${evt.event === "error" ? evt.error.message : "closed"}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Redis-backed `WsBroker`. Two RESP clients are opened against the
|
||||
* supplied URL — one for replies (publish/buffer ops) and one held in
|
||||
* subscribe-mode for the inbound pub/sub stream. Pattern-subscription is
|
||||
* established eagerly at construction time so the first publish is visible
|
||||
* to peers without further setup.
|
||||
*
|
||||
* Lifecycle:
|
||||
*
|
||||
* 1. `createRedisWsBroker(opts)` parses + resolves options, opens both
|
||||
* clients, awaits ready on each, then `PSUBSCRIBE`s on
|
||||
* `<channelPrefix>*`.
|
||||
* 2. `broker.broadcast` and `broker.publish` (alias) encode the envelope
|
||||
* and `PUBLISH` to `<channelPrefix><topic>`.
|
||||
* 3. The subscribe loop decodes each frame and routes via
|
||||
* {@link LocalSubscriberRegistry}.
|
||||
* 4. `broker.dispose()` `PUNSUBSCRIBE`s, quits both clients, and refuses
|
||||
* subsequent calls.
|
||||
*/
|
||||
export async function createRedisWsBroker<Payload = unknown>(
|
||||
options: CreateRedisWsBrokerOptions
|
||||
): Promise<RedisWsBroker<Payload>> {
|
||||
// Strip broker-factory-only fields before schema validation; the schema
|
||||
// describes the wire shape (RedisWsBrokerOptions), not the extra
|
||||
// factory-only knobs (clientFactory, readyTimeoutMs).
|
||||
const { clientFactory: _cf, readyTimeoutMs: _rt, ...schemaInput } = options;
|
||||
const parsed = redisWsBrokerOptionsSchema.safeParse(schemaInput);
|
||||
if (!parsed.success) {
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONFIG_INVALID,
|
||||
`createRedisWsBroker received invalid options: ${parsed.error.message}`
|
||||
);
|
||||
}
|
||||
const resolved = resolveRedisWsBrokerOptions(
|
||||
parsed.data as RedisWsBrokerOptions
|
||||
);
|
||||
const factory: RedisClientFactory =
|
||||
options.clientFactory ??
|
||||
((url, role) =>
|
||||
createRespClient({
|
||||
url,
|
||||
commandTimeoutMs: resolved.commandTimeoutMs,
|
||||
isSubscriber: role === "subscriber",
|
||||
}));
|
||||
const publisher = factory(resolved.connection, "publisher");
|
||||
const subscriber = factory(resolved.connection, "subscriber");
|
||||
const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
await Promise.all([
|
||||
waitForReady(publisher, readyTimeout),
|
||||
waitForReady(subscriber, readyTimeout),
|
||||
]);
|
||||
|
||||
const registry = new LocalSubscriberRegistry<Payload>();
|
||||
let disposed = false;
|
||||
let lastError: string | undefined;
|
||||
const pattern = `${resolved.channelPrefix}*`;
|
||||
|
||||
const detachSub = subscriber.on((evt) => {
|
||||
if (evt.event === "message") {
|
||||
const topic = topicFromChannel(evt.channel, resolved.channelPrefix);
|
||||
if (topic === null) return;
|
||||
void dispatchInboundFrame(registry, topic, evt.payload, (err) => {
|
||||
lastError = err.message;
|
||||
});
|
||||
} else if (evt.event === "close" || evt.event === "error") {
|
||||
connectionLostCounter().add(1);
|
||||
if (evt.event === "error") lastError = evt.error.message;
|
||||
}
|
||||
});
|
||||
const detachPub = publisher.on((evt) => {
|
||||
if (evt.event === "close" || evt.event === "error") {
|
||||
connectionLostCounter().add(1);
|
||||
if (evt.event === "error") lastError = evt.error.message;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await subscriber.psubscribe(pattern);
|
||||
} catch (err) {
|
||||
await publisher.quit().catch(() => undefined);
|
||||
await subscriber.quit().catch(() => undefined);
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.SUBSCRIBE_FAILED,
|
||||
`PSUBSCRIBE failed: ${(err as Error).message}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
function assertLive(operation: string): void {
|
||||
if (disposed) {
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.CONNECTION_LOST,
|
||||
`cannot ${operation}: broker has been disposed`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishImpl(
|
||||
topic: string,
|
||||
envelope: BrokerEnvelope<Payload>
|
||||
): Promise<void> {
|
||||
assertLive("publish");
|
||||
const start = Date.now();
|
||||
const stamped: BrokerEnvelope<Payload> = {
|
||||
...envelope,
|
||||
originHost: envelope.originHost ?? resolved.hostId,
|
||||
};
|
||||
let frame: ReturnType<typeof encodeEnvelope<Payload>>;
|
||||
try {
|
||||
frame = encodeEnvelope(stamped, {
|
||||
compressionThresholdBytes: resolved.compressionThresholdBytes,
|
||||
disableCompression: resolved.disableCompression,
|
||||
});
|
||||
} catch (err) {
|
||||
publishesCounter().add(1, { topic, outcome: "error" });
|
||||
throw err;
|
||||
}
|
||||
const channel = channelFromTopic(topic, resolved.channelPrefix);
|
||||
try {
|
||||
await publisher.publish(channel, frame.bytes);
|
||||
publishesCounter().add(1, { topic, outcome: "ok" });
|
||||
publishDurationHistogram().record(Date.now() - start);
|
||||
} catch (err) {
|
||||
publishesCounter().add(1, { topic, outcome: "error" });
|
||||
throw new WsBrokerRedisError(
|
||||
WS_BROKER_REDIS_CODES.PUBLISH_FAILED,
|
||||
`PUBLISH failed for topic=${topic}: ${(err as Error).message}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const broker: RedisWsBroker<Payload> = {
|
||||
options: resolved,
|
||||
async publish(topic, envelope): Promise<void> {
|
||||
await publishImpl(topic, envelope);
|
||||
},
|
||||
async subscribe(
|
||||
topic: string,
|
||||
handler: BrokerHandler<Payload>
|
||||
): Promise<BrokerSubscription> {
|
||||
assertLive("subscribe");
|
||||
registry.add(topic, handler);
|
||||
let active = true;
|
||||
return {
|
||||
topic,
|
||||
unsubscribe(): void {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
registry.remove(topic, handler);
|
||||
},
|
||||
};
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
registry.clear();
|
||||
detachSub();
|
||||
detachPub();
|
||||
try {
|
||||
await subscriber.punsubscribe(pattern);
|
||||
} catch {
|
||||
/* socket may have already closed; safe to ignore */
|
||||
}
|
||||
await Promise.all([
|
||||
publisher.quit().catch(() => undefined),
|
||||
subscriber.quit().catch(() => undefined),
|
||||
]);
|
||||
},
|
||||
async writeBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
event: BufferedEvent
|
||||
): Promise<void> {
|
||||
assertLive("writeBuffer");
|
||||
await writeBufferedEvent(publisher, tenant, topic, event, {
|
||||
keyPrefix: resolved.keyPrefix,
|
||||
maxEvents: resolved.eventBufferMaxEvents,
|
||||
ttlSec: resolved.eventBufferTtlSec,
|
||||
});
|
||||
},
|
||||
async readBuffer(
|
||||
tenant: TenantId,
|
||||
topic: string,
|
||||
sinceEventId: number
|
||||
): Promise<readonly BufferedEvent[]> {
|
||||
assertLive("readBuffer");
|
||||
return readBufferedEvents(publisher, tenant, topic, sinceEventId, {
|
||||
keyPrefix: resolved.keyPrefix,
|
||||
maxEvents: resolved.eventBufferMaxEvents,
|
||||
ttlSec: resolved.eventBufferTtlSec,
|
||||
});
|
||||
},
|
||||
healthz(): BrokerHealthSnapshot {
|
||||
return evaluateBrokerHealth(publisher, subscriber, lastError);
|
||||
},
|
||||
isDisposed(): boolean {
|
||||
return disposed;
|
||||
},
|
||||
};
|
||||
return broker;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type CreateRedisWsBrokerOptions,
|
||||
createRedisWsBroker,
|
||||
type RedisClientFactory,
|
||||
type RedisWsBroker,
|
||||
} from "./broker.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
WS_BROKER_REDIS_CODES,
|
||||
WsBrokerRedisError,
|
||||
type WsBrokerRedisErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type BufferedEvent,
|
||||
bufferKey,
|
||||
decodeBufferMember,
|
||||
type EventBufferOptions,
|
||||
encodeBufferMember,
|
||||
readBufferedEvents,
|
||||
writeBufferedEvent,
|
||||
} from "./event-buffer.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type BrokerHealthSnapshot,
|
||||
type BrokerHealthStatus,
|
||||
evaluateBrokerHealth,
|
||||
} from "./health.js";
|
||||
63
packages/api-stack/api/api-ws-broker-redis/src/index.ts
Normal file
63
packages/api-stack/api/api-ws-broker-redis/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
bufferReadsCounter,
|
||||
bufferWritesCounter,
|
||||
connectionLostCounter,
|
||||
publishDurationHistogram,
|
||||
publishesCounter,
|
||||
receivesCounter,
|
||||
resetBrokerMetricsForTests,
|
||||
} from "./instruments.js";
|
||||
@@ -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.*`).
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
decodeEnvelope,
|
||||
ENCODING_GZIP_JSON,
|
||||
ENCODING_JSON,
|
||||
type EncodedFrame,
|
||||
encodeEnvelope,
|
||||
} from "./publish.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { nextReconnectDelayMs, shouldGiveUp } from "./reconnect.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createRespClient,
|
||||
type RedisPubSubLike,
|
||||
type RedisPubSubListener,
|
||||
type RespClientOptions,
|
||||
} from "./resp-client.js";
|
||||
@@ -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.)
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
channelFromTopic,
|
||||
dispatchInboundFrame,
|
||||
LocalSubscriberRegistry,
|
||||
topicFromChannel,
|
||||
} from "./subscribe.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { RedisFixture } from "@suaveplan/testing-integration-redis";
|
||||
import { createRedisWsBroker } from "../../src/broker/broker.js";
|
||||
import {
|
||||
bufferKey,
|
||||
decodeBufferMember,
|
||||
} from "../../src/event-buffer/event-buffer.js";
|
||||
import { createRespClient } from "../../src/resp-client/resp-client.js";
|
||||
|
||||
const tenant = "tenant-it" as unknown as TenantId;
|
||||
|
||||
let fixture: RedisFixture | undefined;
|
||||
let url: string | undefined;
|
||||
let dockerAvailable = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new RedisFixture();
|
||||
try {
|
||||
const config = await fixture.start();
|
||||
url = config.url;
|
||||
dockerAvailable = true;
|
||||
} catch {
|
||||
// Docker not available — every test below skips itself via dockerAvailable.
|
||||
dockerAvailable = false;
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (fixture && dockerAvailable) {
|
||||
await fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
function skipIfNoDocker(): boolean {
|
||||
if (!dockerAvailable) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("Redis broker — real-container integration", () => {
|
||||
it("publish from broker A is delivered to subscriber on broker B", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
if (skipIfNoDocker()) return;
|
||||
const brokerA = await createRedisWsBroker<{ id: number }>({
|
||||
connection: url!,
|
||||
hostId: "host-A",
|
||||
channelPrefix: "it.broadcast.",
|
||||
keyPrefix: "it.ws:",
|
||||
});
|
||||
const brokerB = await createRedisWsBroker<{ id: number }>({
|
||||
connection: url!,
|
||||
hostId: "host-B",
|
||||
channelPrefix: "it.broadcast.",
|
||||
keyPrefix: "it.ws:",
|
||||
});
|
||||
try {
|
||||
const received: Array<BrokerEnvelope<{ id: number }>> = [];
|
||||
await brokerB.subscribe("orders", (env) => {
|
||||
received.push(env);
|
||||
});
|
||||
// Tiny delay so the SUBSCRIBE is established before the publish.
|
||||
await new Promise<void>((r) => setTimeout(r, 100));
|
||||
await brokerA.publish("orders", {
|
||||
topic: "orders",
|
||||
tenant,
|
||||
payload: { id: 1 },
|
||||
});
|
||||
// Wait for fan-out
|
||||
for (let i = 0; i < 50 && received.length === 0; i += 1) {
|
||||
await new Promise<void>((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(received.length).toBe(1);
|
||||
expect(received[0]!.payload.id).toBe(1);
|
||||
expect(received[0]!.originHost).toBe("host-A");
|
||||
} finally {
|
||||
await brokerA.dispose();
|
||||
await brokerB.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("event buffer sorted-set: write, cap, and replay round-trip on a real Redis", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
if (skipIfNoDocker()) return;
|
||||
const broker = await createRedisWsBroker({
|
||||
connection: url!,
|
||||
channelPrefix: "it.broadcast2.",
|
||||
keyPrefix: "it.ws2:",
|
||||
eventBufferMaxEvents: 4,
|
||||
eventBufferTtlSec: 60,
|
||||
});
|
||||
try {
|
||||
for (let i = 1; i <= 6; i += 1) {
|
||||
await broker.writeBuffer(tenant, "feed", {
|
||||
eventId: i,
|
||||
payload: new Uint8Array([i, i + 1]),
|
||||
});
|
||||
}
|
||||
const events = await broker.readBuffer(tenant, "feed", 0);
|
||||
// Cap = 4, so eventIds 3..6 survive.
|
||||
expect(events.map((e) => e.eventId)).toEqual([3, 4, 5, 6]);
|
||||
|
||||
const tail = await broker.readBuffer(tenant, "feed", 4);
|
||||
expect(tail.map((e) => e.eventId)).toEqual([5, 6]);
|
||||
|
||||
// Independently confirm via raw client that the TTL is set.
|
||||
const raw = createRespClient({ url: url! });
|
||||
await new Promise<void>((r) => setTimeout(r, 50));
|
||||
const key = bufferKey("it.ws2:", tenant, "feed");
|
||||
const card = await raw.zcard(key);
|
||||
expect(card).toBe(4);
|
||||
// Member format is "<id>|<base64>" — decodeBufferMember keeps payload intact.
|
||||
const range = await raw.zrangebyscore(key, "-inf", "+inf");
|
||||
expect(range.length).toBe(4);
|
||||
const decoded = decodeBufferMember(range[0]!);
|
||||
expect(decoded.eventId).toBe(3);
|
||||
await raw.quit();
|
||||
} finally {
|
||||
await broker.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("healthz reports up against a live container and surfaces resolved options", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
if (skipIfNoDocker()) return;
|
||||
const broker = await createRedisWsBroker({
|
||||
connection: url!,
|
||||
channelPrefix: "it.broadcast3.",
|
||||
keyPrefix: "it.ws3:",
|
||||
});
|
||||
try {
|
||||
expect(broker.healthz().status).toBe("up");
|
||||
expect(broker.options.keyPrefix).toBe("it.ws3:");
|
||||
expect(broker.options.channelPrefix).toBe("it.broadcast3.");
|
||||
} finally {
|
||||
await broker.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("compressed payload round-trips end-to-end through real Redis", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
if (skipIfNoDocker()) return;
|
||||
const broker = await createRedisWsBroker<{ big: string }>({
|
||||
connection: url!,
|
||||
channelPrefix: "it.broadcast4.",
|
||||
keyPrefix: "it.ws4:",
|
||||
compressionThresholdBytes: 64,
|
||||
});
|
||||
try {
|
||||
const received: Array<BrokerEnvelope<{ big: string }>> = [];
|
||||
await broker.subscribe("compressed", (e) => {
|
||||
received.push(e);
|
||||
});
|
||||
await new Promise<void>((r) => setTimeout(r, 100));
|
||||
const big = "abcdef".repeat(2_000);
|
||||
await broker.publish("compressed", {
|
||||
topic: "compressed",
|
||||
tenant,
|
||||
payload: { big },
|
||||
});
|
||||
for (let i = 0; i < 50 && received.length === 0; i += 1) {
|
||||
await new Promise<void>((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(received.length).toBe(1);
|
||||
expect(received[0]!.payload.big).toBe(big);
|
||||
} finally {
|
||||
await broker.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-ws-broker-redis/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-ws-broker-redis/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
36
packages/api-stack/api/api-ws-broker-redis/vite.config.ts
Normal file
36
packages/api-stack/api/api-ws-broker-redis/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-ws-broker-redis",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-ws-broker-redis/vitest.config.ts
Normal file
31
packages/api-stack/api/api-ws-broker-redis/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user