4.1 KiB
resp-client — minimal built-in Redis client
(no submodules)
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
datachunks. - 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 handlespmessage/psubscribe/punsubscribeack frames and forwards messages through the listener API. - Injectable
socketFactoryso tests can drive the parser end-to-end against aFakeSocketwithout 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
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
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.)