first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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