Node.js client
Every example on this page was run against a live Ruvio server (standalone on one port, three shards on three ports) with Node 22 before it was pasted here. Output lines marked // => are what that run printed. Where something was not run, the page says so.
Overview
ruvio-client is an async client for Ruvio, written in TypeScript. It shares one pipelined socket between all concurrent callers. Each method is named after the command it sends, and call sends anything else.
| Package | ruvio-client. See Install. |
|---|---|
| Version | Repository version in package.json |
| License | MIT |
| Runtime | Node.js 18 or newer is intended (engines). Only Node 22 was tested. ESM only, with .d.ts files. |
| Model | Async (promises). One multiplexed connection per client, shared by concurrent callers. |
| Dependencies | None at runtime. ioredis is a dev dependency used only by the benchmark. |
| Protocol | RESP2 by default. protocol: 3 sends HELLO 3 (not run on this page). |
| Cluster | Yes. createClusterClient takes one seed address, reads CLUSTER SLOTS and routes by hash slot. It falls back to a plain connection on a standalone server. |
| Command coverage | 287 of 287 commands in docs/compatibility-matrix.json, checked by test/coverage.test.ts. |
Install
Use the repository checkout:
git clone https://github.com/salihcantekin/ruvio cd ruvio/clients/node npm install # dev dependencies only (typescript, @types/node, ioredis for the benchmark) npm run build # writes dist/ (ESM + .d.ts) # then, in your own project: npm install ../ruvio/clients/node
The last step (installing from the local path) was not tried; the tests and all examples here import the built dist/index.js directly. Registry release is pending.
Quick start
import { createClient } from 'ruvio-client';
const c = createClient({ host: '127.0.0.1', port: 17391, db: 9 }); // or a URL string
await c.set('session:ada', 'hello', { px: 5000 });
console.log(await c.get('session:ada')); // => hello
console.log(await c.get('nope')); // => null
console.log(await c.binary.get('session:ada')); // => <Buffer 68 65 6c 6c 6f>
console.log(await c.incr('visits')); // => 1
console.log(await c.ttl('session:ada'), await c.pttl('session:ada')); // => 5 4996
console.log(await c.set('session:ada', 'zzz', { nx: true })); // => null (key exists)
await c.quit(); // waits for in-flight replies, then closes
createClient also accepts a URL: redis://user:pass@host:port/db (rediss:// or ruvios:// turns TLS on). Options: host, port, path (unix socket), tls, username, password, db, name (CLIENT SETNAME), protocol (2 or 3), connectTimeoutMs, noDelay, keepAlive, flush ('microtask' or 'immediate'), reconnect (default true) and readBufferSize.
The socket opens on the first command, or earlier with await c.connect(). db, name and credentials are applied again after a reconnect. Commands take strings, Buffers or (for values) numbers; replies are strings, or Buffers through c.binary.
AUTH, TLS and unix sockets exist in the code but were not run on this page.
Performance
- One socket, many callers. Concurrent calls share the connection. Replies resolve in order from a ring-buffer queue (no
Array.shift, no per-call closure). - Write coalescing, no timers. Frames queued in the same tick go out in one
socket.write. A serial call is sent at once.flush: 'immediate'also collects callers resumed by the same I/O event. - Pre-encoded commands. Command names are cached as complete RESP fragments. Integers are written as ASCII digits into a reusable 64 KiB slab. Buffers of 64 KiB or more go to the socket without a copy.
- In-place, resumable parser. It works on the socket's reusable read buffer and copies only an unfinished tail, so a large array that arrives in many reads is parsed in linear time. Strings are decoded with
utf8Slice. - Binary API.
c.binary.*has every command and returnsBuffers.
The numbers below are copied from the client README. They come from a shared 4-vCPU VM, Node 22, loopback, 256-byte values, 1.5 s per case, one connection, one Ruvio process. They are noisy and are not release evidence. I did not re-run the benchmark for this page. The README itself says serial numbers vary a lot between runs (earlier runs of the same code gave 25k to 31k).
| Client (ops/s) | GET c=1 | SET c=1 | GET c=128 | SET c=128 |
|---|---|---|---|---|
| ruvio-client (microtask flush) | 13,787 | 14,996 | 605,419 | 755,983 |
| ruvio-client (setImmediate flush) | 13,912 | 13,897 | 641,112 | 697,912 |
| ioredis 5 (same run) | 11,228 | 12,291 | 104,733 | 92,169 |
| ioredis 5, autoPipelining | 8,794 | 8,768 | 146,256 | 132,264 |
pipeline() of 128 GETs | 663,990 | |||
Codec timings from the same README (no I/O): encode GET about 66 ns, encode SET with a 256-byte string about 230 ns (Buffer about 130 ns), parse +OK about 20 ns, :int about 57 ns, nil about 37 ns, a 256-byte bulk to string about 240 ns. The README's bytes-per-op column is a heap-delta estimate, so it is not quoted here.
For best speed: reuse one client, let concurrent calls overlap (Promise.all) instead of awaiting one by one, use pipeline() for batches, and use a Ruvio command that replaces a round trip (for example set(k, v, { ifEq }) instead of GET then SET).
Commands
Every command in the compatibility matrix has a typed method. Names are the camel case of the command (XGROUP CREATE is xgroupCreate, BF.MADD is bfMadd). The method list is the Commands class in src/commands.ts. Command syntax is on Commands.
| Family | Examples |
|---|---|
| Strings | get, set (options ex, px, nx, xx, get, keepTtl, ifEq), mget, mset, incr, incrbyfloat, getdel, getrange |
| Keys | del, exists, expire, ttl, pttl, rename, copy, scan, dump, restore |
| Hashes | hset, hget, hgetall, hscan |
| Lists | lpush, rpush, lpop, lrange, blpop |
| Sets | sadd, smembers, sscan |
| Sorted sets | zadd, zrange, zrangeWithScores, zrevrangeWithScores, zscore, zscan |
| Streams | xadd, xrange, xread, xgroupCreate, xreadgroup, xack, xinfoStream |
| JSON | jsonSet, jsonGet, jsonIndexCreate, jsonFind |
| Geo | geoadd, geodist, geosearch |
| Bitmaps / HyperLogLog | setbit, bitcount, pfadd, pfcount, pfmerge |
| Bloom filters | bfReserve, bfMadd, bfMexists |
| Transactions | multi(), watch, unwatch |
| Pub/Sub | publish, spublish, and subscriber() for the receiving side |
| Server, cluster | ping, info, dbsize, clusterSlots, clusterKeyslot |
The table lists only methods I checked in the source or ran. The remaining methods (other string, key, list, set, stream, script, ACL and client commands) follow the same naming; the coverage test proves each matrix command has one. Typed examples, run against a standalone server (database 9):
// Strings and expiry
await c.mset({ a: '1', b: '2' }); // => 'OK'
await c.mget('a', 'b', 'c'); // => [ '1', '2', null ]
// Hashes
await c.hset('user:1', { name: 'Ada', plan: 'pro' });
await c.hget('user:1', 'name'); // => 'Ada'
await c.hgetall('user:1'); // => { name: 'Ada', plan: 'pro' }
// Lists
await c.rpush('jobs', 'a', 'b', 'c');
await c.lrange('jobs', 0, -1); // => [ 'a', 'b', 'c' ]
await c.lpop('jobs'); // => 'a'
// Sets
await c.sadd('tags', 'x', 'y');
await c.smembers('tags'); // => [ 'x', 'y' ] (order not fixed by the server)
// Sorted sets: ZADD ... GT CH
await c.zadd('lb', [[100, 'player1'], [80, 'player2']]);
await c.zadd('lb', 90, 'player2', { gt: true, ch: true }); // => 1
await c.zrevrangeWithScores('lb', 0, 9);
// => [ { member: 'player1', score: 100 }, { member: 'player2', score: 90 } ]
await c.zscore('lb', 'player1'); // => 100
// Streams with a consumer group (Ruvio: XREADGROUP takes one stream and '>')
await c.xadd('events', '*', { kind: 'signup', user: 'ada' });
await c.xgroupCreate('events', 'workers', '0');
const got = await c.xreadgroup('workers', 'w1', 'events', { count: 10 });
// => [ { key: 'events', entries: [ { id: '...-0', fields: { kind: 'signup', user: 'ada' } } ] } ]
await c.xack('events', 'workers', got![0].entries[0].id); // => 1
// JSON (values are JSON text)
await c.jsonSet('user:ada', '$', JSON.stringify({ name: 'Ada', city: 'Berlin', tags: ['a'] }));
await c.jsonGet('user:ada', '$.name'); // => '"Ada"'
// Geo
await c.geoadd('cities', [
{ longitude: 13.361389, latitude: 38.115556, member: 'Palermo' },
{ longitude: 15.087269, latitude: 37.502669, member: 'Catania' },
]); // => 2
await c.geodist('cities', 'Palermo', 'Catania', 'km'); // => 166.27415156960032
await c.geosearch('cities', { fromLonLat: [15, 37], byRadius: { radius: 200, unit: 'km' }, order: 'ASC' });
// => [ 'Catania', 'Palermo' ]
// Bitmaps and HyperLogLog
await c.setbit('online', 7, 1);
await c.bitcount('online'); // => 1
await c.pfadd('visitors', 'u1', 'u2', 'u3'); // => true
await c.pfcount('visitors'); // => 3
// Anything else: send the arguments as redis-cli would
await c.call('OBJECT', 'ENCODING', 'a'); // => 'int'
A wrong argument type throws before anything is sent. I first passed an object as the stream to xreadgroup and got ruvio: unsupported argument type object; the stream key is a plain argument. call returns the raw reply (string, number, array or null). The EXPIRE family takes no NX/XX/GT/LT because the server rejects them.
Ruvio commands
These are the commands Redis does not have, or has with different semantics. Replies below are from a live run.
// SET key value IFEQ expected (compare-and-set; replaces WATCH/MULTI or a Lua script)
await c.set('cfg', 'v1');
await c.set('cfg', 'v2', { ifEq: 'v1', px: 60000 }); // => 'OK'
await c.set('cfg', 'v3', { ifEq: 'v1' }); // => null (value is now v2)
// DELEX key IFEQ value (delete only if equal; the safe lock release, no Lua)
await c.delex('cfg', 'nope'); // => false
await c.delex('cfg', 'v2'); // => true
// INCRBY key delta MAX limit (bounded counter)
await c.incrbyMax('stock', 1, 2); // => 1, then 2, then null (would pass 2)
// LIMIT key max windowMs (fixed-window rate limit; number = calls left, null = refused)
await c.limit('api:ada', 2, 60000); // => 1, 0, null
// FILLGET / FILLSET (cache fill with stampede protection)
const f1 = await c.fillget('page', 5000); // => { state: 'fill', value: null, token: '6' }
await c.fillget('page', 5000); // => { state: 'busy', value: null, token: null }
await c.fillset('page', '<html>', 60000, f1.token!); // => true
await c.fillget('page', 5000); // => { state: 'hit', value: '<html>', token: null }
// LEASE / SEMAPHORE / RELEASE
await c.lease('leader', 10000); // => 1 (fence token), then null
const tk = await c.semaphore('dl', 1, 10000); // => '1878847355437973504'
await c.semaphore('dl', 1, 10000); // => null (full)
await c.release('dl', tk!); // => true
// ONCE key ttlMs (true on the first call, false afterwards)
await c.once('job:1', 60000); // => true, then false
// TAKE key count (take from a numeric stock; null when there is not enough)
await c.set('tickets', '5');
await c.take('tickets', 2); // => 3 (left)
await c.take('tickets', 9); // => null
// SETV key value [version] (versioned write)
await c.setv('doc', 'one'); // => 1
await c.setv('doc', 'two', 1); // => 2
// GETEX key ttlMs (read and set a millisecond TTL)
await c.set('g', 'hello');
await c.getex('g', 5000); // => 'hello', pttl is now 5000
// LPUSHCAP key cap v... (push and trim in one command)
await c.lpushcap('recent', 3, '1', '2', '3', '4', '5'); // => 3
await c.lrange('recent', 0, -1); // => [ '5', '4', '3' ]
// Bloom filter
await c.bfReserve('seen', 0.01, 1000); // => 'OK'
await c.bfMadd('seen', 'a', 'b'); // => [ true, true ]
await c.bfMexists('seen', 'a', 'z'); // => [ true, false ]
// JSON.INDEX / JSON.FIND (database 0 only)
await c.jsonSet('user:1', '$', '{"city":"Berlin"}');
await c.jsonIndexCreate('by_city', 'user:', '$.city', 'string'); // => 'OK'
await c.jsonFind('by_city', '"Berlin"', { count: 100 }); // => [ 'user:1' ]
// CHANGES
const cur = await c.changesStart(); // => 65
await c.set('watched', '1');
await c.changes(cur); // => [ { seq: 66, note: 'SET watched' } ]
JSON.FIND right after the create answered ERR index is building four times in my run (50 ms apart) before it returned the key, so retry it on that error. JSON.INDEX CREATE works in database 0 only. fillget with a third waitMs argument sends FILLGET ... WAIT and parks the shared connection; use it on a duplicate() client only. rpushcap, xadd with delayMs, and the raw idemBegin/idemComplete exist; only idemBegin/idemComplete were run (through idempotent, below).
Helpers
Thin layers over the native commands. They use only single-key commands, so they work unchanged on a ClusterClient (lock, rate limit and getOrCreate were run on three shards, see Cluster).
// Lock: SET key token NX PX, renew with SET IFEQ, release with DELEX IFEQ
const lock = await c.acquireLock('lock:report', 30000); // null if someone else holds it
if (lock) {
try {
await lock.renew(30000); // => true
} finally {
await lock.release(); // => true; a second release() => false
}
}
await c.withLock('lock:job', 30000, async () => c.incr('jobs')); // => 1; releases even if fn throws
// A lock someone else holds
await c.acquireLock('lock:busy', 5000, { waitMs: 300 }); // => null (after ~300 ms)
await c.withLock('lock:busy', 5000, async () => 1); // throws AcquireTimeoutError
Failure behavior. acquireLock returns null when the lock is held and waitMs (default 0: one try) runs out. withLock throws AcquireTimeoutError instead. If the lock expired and someone else took it, renew() returns false and release() deletes nothing: DELEX IFEQ only removes your own token (checked: the other holder's value was still there after a failed acquire). The lock cannot tell you mid-work that it expired; check renew() during long work.
Lock, Lease and Permit also implement Symbol.asyncDispose, so await using lock = await c.acquireLock(...) releases at scope end. That syntax was not run. Node 22.22 here fails to parse await using in a plain .mjs file (SyntaxError: Unexpected identifier).
It needs TypeScript 5.2+ to compile it down, or a newer Node. The README says "Node 22+" for it, which was not true on this machine. The release() calls above work everywhere.
// Fenced lease and semaphore permit
const lease = await c.acquireLease('leader', 10000); // lease.token => 1 (fencing token); a second acquire => null
await lease!.release(); // => true
const permit = await c.acquireSemaphore('downloads', 1, 60000); // a second acquire => null (full)
await permit!.release();
// Rate limit: LIMIT -> { allowed, remaining }
for (let i = 0; i < 3; i++) console.log(await c.rateLimit('login:ada', 2, 60000));
// => { allowed: true, remaining: 1 }, { allowed: true, remaining: 0 }, { allowed: false, remaining: 0 }
// Cache-aside with stampede protection (FILLGET / FILLSET)
const page = await c.getOrCreate('page:home', 60000, async () => '<html>home</html>');
// A second call does not run the factory. Four concurrent calls on a cold key ran it once.
acquireLease and acquireSemaphore take { waitMs, retryMs } like acquireLock and return null when held or full. getOrCreate polls plain FILLGET with a 2 to 50 ms backoff while another caller fills, because WAIT would park the shared socket.
It throws AcquireTimeoutError after waitMs (default fillMs + 1000). If the factory throws, nothing is stored and the fill marker lapses after fillMs (default 10 s). With staleMs an expired value is served while one caller refreshes it in the background. The timeout and stale paths were not run here.
// Idempotency (IDEM BEGIN / COMPLETE)
await c.idempotent('pay:42', 'fp-1', 3600000, async () => 'charged');
// => { result: 'charged', replayed: false }
await c.idempotent('pay:42', 'fp-1', 3600000, async () => 'charged');
// => { result: 'charged', replayed: true } (the work did not run)
await c.idempotent('pay:42', 'other-fp', 3600000, async () => 'x');
// throws IdempotencyConflictError
While another owner is running the same key, idempotent throws IdempotencyPendingError at once (or polls until waitMs). If fn throws, the reservation stays pending until the TTL passes. This is not exactly-once for external side effects. The pending path was not run here.
// Lazy scan iterators: one page in memory at a time
const keys: string[] = [];
for await (const k of c.scanIterator({ match: 'session:*', count: 2 })) keys.push(k); // 5 keys
for await (const [field, value] of c.hscanIterator('h')) { /* => a 1, b 2 */ }
// sscanIterator(key) yields members, zscanIterator(key) yields { member, score }
// Typed JSON: JSON.stringify / JSON.parse around JSON.SET / JSON.GET
await c.jsonSetAs('u:t', '$', { name: 'Ada', city: 'Izmir' }); // => true
await c.jsonGetAs('u:t', '$'); // => { name: 'Ada', city: 'Izmir' }
await c.jsonGetAs('u:t', '$.city'); // => 'Izmir'
await c.jsonGetAs('nokey'); // => null
// Capped recent list (LPUSHCAP) and Bloom "seen before" (BF.MADD)
for (let i = 0; i < 5; i++) await c.pushRecent('recent:ada', 3, 'item:' + i);
await c.recent('recent:ada', 10); // => [ 'item:4', 'item:3', 'item:2' ]
await c.seenBefore('emails:seen', 'a@x', 'b@x'); // => [ false, false ]
await c.seenBefore('emails:seen', 'a@x', 'c@x'); // => [ true, false ] (Bloom: true means "probably")
await c.mightContain('emails:seen', 'a@x', 'zz'); // => [ true, false ] (does not record)
jsonGetAs<T> and jsonSetAs are not checked against a schema: the type parameter is only a cast. A missing key gives null; invalid JSON text from the server would throw from JSON.parse.
Pipelines, transactions, Pub/Sub, blocking
// Pipeline: one write, replies in order
await c.pipeline().set('a', 1).incr('a').get('a').exec(); // => [ 'OK', 2, '2' ]
// A failing command stays in its own slot; the others still run
await c.set('s', 'x');
await c.pipeline().set('s', 'x').lpush('s', 'v').get('s').exec();
// => [ 'OK', RuvioError: WRONGTYPE ..., 'x' ]
await c.pipeline().set('s', 'x').lpush('s', 'v').execStrict(); // rejects with that RuvioError (code WRONGTYPE)
// Transaction: MULTI .. EXEC in one contiguous write
await c.set('balance', '100');
await c.multi().incrby('balance', 10).get('balance').exec(); // => [ 110, '110' ]
// WATCH needs its own connection
await c.isolated(async (iso) => {
await iso.watch('balance');
await c.incr('balance'); // another client changes it
await iso.multi().incrby('balance', 10).exec(); // => null (WATCH failed)
});await c.isolated(async (iso) => {
await iso.watch('balance'); // nobody touches it this time
await iso.multi().incrby('balance', 10).get('balance').exec(); // => [ 121, '121' ]
});
// Pub/Sub: a dedicated socket
const sub = c.subscriber();
sub.on('message', (m) => console.log(m)); // => { kind: 'message', channel: 'news', message: 'hello' }
await sub.subscribe('news');
await c.publish('news', 'hello'); // => 1 receiver
sub.close();
// also: for await (const m of sub) { ... }, psubscribe, ssubscribe, subscriber({ binary: true })
// Blocking commands park the shared socket: use a second client
const blk = c.duplicate();
const pending = blk.blpop('queue', 2); // timeout in seconds
await c.rpush('queue', 'job-1');
await pending; // => [ 'queue', 'job-1' ]
await blk.blpop('empty', 0.2); // => null
await blk.quit();
A blocking call (blpop, xread or xreadgroup with block, fillget with waitMs, wait) occupies the shared socket until it returns, so every other caller of that client waits.
Use duplicate() or isolated(). The subscriber re-subscribes after a dropped connection (delay reconnectDelayMs, default 100 ms, doubling to 5 s); that reconnect was not run here. protocol: 3 (maps as objects, push events, CLIENT TRACKING invalidations) is implemented but was not run on this page. A pipeline cannot be re-run after exec().
Cluster
Pass one seed address to createClusterClient. On the first command (or await c.connect()) it sends CLUSTER SLOTS to the seed and builds a 16384-entry slot table (CRC16 of the key or of its {hash tag}, modulo 16384).
It keeps one lazy multiplexed connection per node. A standalone server answers ERR cluster mode is disabled; the client then behaves like RuvioClient on that one connection (I ran set against the standalone port: OK, isCluster false). cluster: true makes a standalone seed an error instead. Only database 0 exists in cluster mode.
- Single key: sent to the owner node. The key is read from the encoded frame, so no extra key strings are built.
- Split and gathered:
MGET,MSET,DEL,UNLINK,EXISTS,TOUCHover several slots run as one sub-command per slot, concurrently. Replies come back in your order and counts are summed. They are not atomic: if one part fails the call rejects, and other parts may have run. - Every node:
DBSIZE,KEYS,FLUSHDB,FLUSHALL. Other keyless commands (PING,INFO,CONFIG, ...) go to the seed. - Other multi-key commands (
RENAME,COPY,SUNION,SINTER,BITOP,ZUNION*,LMPOP,XREAD, ...) need all keys in one slot, otherwise aRuvioErrorwith codeCROSSSLOTis thrown before anything is sent. - Pipelines are grouped per node, one write each, replies in your order. Transactions need one slot (else
CROSSSLOT, nothing sent).WATCHusesc.isolated(key, fn). - MOVED: the slot is repointed and the command retried once; a second MOVED is returned as the error. The table is re-read when the node is unknown or more than one slot moved. A command in flight when a socket drops is never replayed: it rejects with
ConnectionError, only that node reconnects, other nodes are unaffected. - Blocking commands use a pooled dedicated connection to the owning node. Pub/Sub:
c.subscriber()connects to the seed;await c.subscriberFor(channel)connects to the node owning the channel (forSSUBSCRIBE/SPUBLISH). - Scan:
c.scanIterator()walks every node one after the other. A plainc.scan(cursor)reaches only the seed node.
Run against three shards (ports 17420 to 17422, seed 17420):
import { createClusterClient, hashSlot } from 'ruvio-client';
const c = createClusterClient('redis://127.0.0.1:17420');
await c.connect();
c.isCluster; // => true
c.nodes.length; // => 3
hashSlot('somekey'); // => 11058 (same as await c.clusterKeyslot('somekey'))
hashSlot('{user1000}.following'); // => 3443 (same slot as '{user1000}.profile')
for (const k of ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot']) await c.set(k, k);
// slots 865, 8623, 1769, 9053, 14438, 3172: all three shards hold keys
await c.mget('foxtrot', 'alpha', 'missing', 'delta', 'bravo');
// => [ 'foxtrot', 'alpha', null, 'delta', 'bravo' ] (caller's order)
await c.mset({ m1: '1', m2: '2', m3: '3', m4: '4' });
await c.del('m1', 'm2', 'm3', 'm4', 'nokey'); // => 4
await c.exists('alpha', 'bravo', 'nokey'); // => 2
await c.pipeline().set('p1', 1).set('p2', 2).get('p1').get('p2').incr('p3').exec();
// => [ 'OK', 'OK', '1', '2', 1 ]
await c.sunion('s1', 's2'); // throws RuvioError, code CROSSSLOT
await c.multi().set('{t}a', '1').set('{t}b', '2').exec(); // => [ 'OK', 'OK' ] (one hash tag, one slot)
await c.multi().set('alpha', '1').set('bravo', '2').exec(); // throws CROSSSLOT, nothing sent
const keys = [];
for await (const k of c.scanIterator()) keys.push(k); // all 11 keys; await c.dbsize() => 11
const lock = await c.acquireLock('lock:x', 5000); // helpers work on the cluster client
await c.rateLimit('rl:ada', 2, 60000); // => { allowed: true, remaining: 1 }
await c.getOrCreate('page', 60000, () => 'v'); // => 'v'
await c.quit();
The cross-slot errors above read CROSSSLOT Keys in request do not hash to the same slot; the client raises them itself, before sending. c.redirectCount and c.refreshCount stayed 0 in this run, so MOVED recovery was not exercised by these examples; the repository's cluster tests cover it (see Testing). Introspection: c.nodeFor(key), c.slotOf(key), c.refreshSlots().
Cluster numbers from the README (same caveats as above, not re-run): 3 shards, one client, 256-byte values, GET c=128 about 659k ops/s and SET c=128 about 523k, against about 839k and 790k for RuvioClient on a standalone server in the same run. The 3-shard server shares the VM's CPUs with the benchmark, so these rows are a lower bound, not a scaling claim.
Errors
Three error classes. RuvioError is a server reply (-ERR ..., -WRONGTYPE ...); .code is its first word. ConnectionError means the socket failed or closed before a reply arrived: the command may or may not have run. ReplyError means a reply had a shape the typed method does not understand.
The helpers add AcquireTimeoutError, IdempotencyConflictError and IdempotencyPendingError. A server error leaves the connection usable.
await c.set('str', 'x');
try {
await c.lpush('str', 'v');
} catch (e) {
console.log(e instanceof RuvioError, e.code, e.message);
// => true WRONGTYPE WRONGTYPE Operation against a key holding the wrong kind of value
}
await c.ping(); // => 'PONG', still usable
await c.get('missing'); // => null (nil is null, not an error)
const bad = createClient({ host: '127.0.0.1', port: 17399 });
await bad.get('x'); // rejects: ConnectionError "ruvio: connection closed: connect ECONNREFUSED 127.0.0.1:17399"
Nil maps to null (get, hget, lpop, blpop, zscore, ...). limit, take, lease, semaphore, incrbyMax and set with nx/ifEq use null for "refused".
Reconnect. After a drop, pending calls reject with ConnectionError and the next call opens the socket again (reconnect is on by default; db and name are applied again). A command that was in flight is not replayed.
The reconnect path was not run on this page. Events: c.on('connect' | 'disconnect' | 'connectError' | 'error' | 'push', fn).
Timeouts and cancellation. The only timeout option is connectTimeoutMs. There is no per-command timeout and no AbortSignal: a call that never gets a reply (for example a blocking command with timeout 0) stays pending. Bound it yourself with Promise.race and a timer, then disconnect() the client (which rejects every in-flight call with ConnectionError), or run the call on a duplicate() client you can throw away.
Not done or not verified
- Release. See the npm registry for the release status.
- Node versions. Only Node 22 was available. Node 18 and 20 are the stated target but were not run.
await usingwas not run (Node 22.22 cannot parse it directly; userelease()or compile with TypeScript 5.2+). The client polyfillsSymbol.asyncDisposefor older Node. - Not run live: TLS, unix-socket connections,
HELLO ... AUTHand AUTH from options,protocol: 3on this page,ACL LOAD/ACL SAVEsuccess (the server has no acl_file),WAITwith real replicas. - Cluster, not done:
ASKredirects and replica reads (READONLY);MOVEDinside a transaction is not retried; splitMSET/DELare not atomic;SELECTis not routed (database 0 only);CLUSTER NODES/SHARDS/INFO/MYIDgo to the seed only; a plainscan()is single-node. Routing is verified only against Ruvio's three-shardper-shardlayout on one machine, not against a Redis Cluster. - Older server builds had quirks the client still tolerates: a blocked
XREAD/XREADGROUPwoken byXADDreplied one entry without the outer list,SUNION/SINTERorder was not sorted, and a queue-time error insideMULTIdid not giveEXECABORT. All three are fixed in the server;EXECnow answersEXECABORTand runs nothing. - Not run on this page:
getOrCreatebusy/timeout/stale paths, the pending path ofidempotent,subscriberFor, Pub/Sub on the cluster, MOVED recovery, reconnect,rpushcap,XADD ... delayMs. - No per-command timeout or cancellation (see Errors).
- Benchmark numbers on this page are copied from the README and were not re-run.
Testing
cd clients/node npm test # codec unit tests + coverage; live tests skip RUVIO_TEST_ADDR=127.0.0.1:17391 npm test # + live tests per command family, cluster client standalone fallback RUVIO_TEST_ADDR=127.0.0.1:17391 RUVIO_CLUSTER_ADDR=127.0.0.1:17420 npm test # + live cluster tests RUVIO_TEST_ADDR=127.0.0.1:17391 npm run bench # codec ns, live GET/SET ops/s, ioredis comparison (not run for this page)
Counts from my run with a live standalone server and a live three-shard server: 63 tests, 63 passed, 0 failed, 0 skipped (Node 22.22, node --test). The tests are in test/: codec.test.ts (encoder and parser), cluster.test.ts (slot table, hash tags, key extraction, MOVED handling against a scripted fake server), coverage.test.ts (reads docs/compatibility-matrix.json and fails if a command has no typed method), and the live files for strings and keys, collections, Bloom/JSON/streams, server commands and cluster.
The live cluster tests include cross-slot MGET/MSET/DEL order, pipeline order, forced stale-table MOVED recovery, transactions and helpers on the cluster. Without the environment variables the live tests skip.
The live tests use their own logical databases and run FLUSHDB; one test runs FLUSHALL, so point them at a throwaway server. Start servers as on Start. For three shards set shards = 3 and port_mode = "per-shard" in the config; shard i listens on base port + i.
See also: Clients overview, Rust, C#, Go, Java, Python, Examples, Commands.