A pure-TypeScript Redis / Valkey driver that speaks the wire protocol
directly — no native addons, no hiredis, no FFI shims. It runs
unchanged on Node.js, Bun, and
Perry, where the same source
is ahead-of-time compiled to a native binary via LLVM — giving you
a real standalone executable that talks to Redis with no JS runtime
attached.
Perry is a TypeScript-to-native compiler: it lowers a strict subset of TS through LLVM into a statically-linked binary.
@perryts/redisis the third reference driver in the family (after@perryts/postgresand@perryts/mysql), showcasing Perry's ability to host real systems code: every socket read, TLS handshake, and crypto op goes through perry-stdlib.
bun add @perryts/redis
npm install @perryts/redis
pnpm add @perryts/redisimport { connect } from '@perryts/redis';
const conn = await connect({ host: '127.0.0.1', port: 6379 });
await conn.command('SET', 'greeting', 'hello from perry');
const got = await conn.command<Buffer>('GET', 'greeting');
console.log(got.value?.toString('utf8')); // → 'hello from perry'
await conn.close();Most Node Redis clients (ioredis, node-redis) work great on V8 but
make assumptions a strict TypeScript-to-native compiler can't honor:
runtime-mutable prototypes, dynamic method dispatch, process.nextTick,
EventEmitter inheritance, require('hiredis') for parsing speed. None
of those translate cleanly to a Perry binary.
@perryts/redis was designed against the Perry AOT constraint set from
day one: module-level state maps instead of this.method closures,
explicit branching instead of ?. / ??, no for-of on arrays, no
dynamic key access. The same source compiles to a native binary AND
runs unchanged on Node.js / Bun.
- RESP2 + RESP3 — both parsers ship from day one.
HELLO 3is tried by default; on-NOPROTOwe fall back to RESP2 + legacy AUTH transparently. - Authentication — legacy
requirepass, ACL (HELLO 3 AUTH user pass), pluggable viaregisterAuthPlugin. - TLS — full TLS-from-SYN via
rediss://(no mid-stream upgrade). - Pipelining —
client.pipeline().add(...).add(...).exec()writes N commands in one syscall; per-command errors don't fail the batch. - Pub/Sub —
client.subscribe(chan, cb)works against both RESP2 array messages and RESP3 push frames; the callback shape is uniform. - Transactions —
client.multi()builder; WATCH-abort returns{ aborted: true }cleanly. - Scripts —
client.eval/client.evalshawith automatic-NOSCRIPTretry via cached source. - Client-side caching —
client.tracking.enable({...})+tracking.on('invalidate', cb)for RESP3 invalidation pushes. - Blocking commands —
client.blockingCommand(args, timeoutMs)sets a pool-visible busy flag so BLPOP / WAIT / XREAD BLOCK don't break pooling. - Cluster — CRC16 slot routing, MOVED/ASK redirection, per-node pools, hashtag co-location.
- Pool — idle set + waiting queue + RESET-on-release.
- Cancel — second-connection
CLIENT KILL ID <id>. - URLs and env vars —
redis://,rediss://,unix://,REDIS_URL,REDIS_HOST,REDIS_PORT,REDIS_USERNAME,REDIS_PASSWORD,REDIS_DB,REDIS_TLS,REDIS_NAME. - Zero native dependencies. Pure TypeScript over
Buffer,node:net,node:tls,node:crypto. Nothing to rebuild per platform. - GUI-shaped result surface.
CommandReplycarries the decoded value, the RESP type tag, and the rawRespValuefor byte-exact round-trip in tools like Tusk.
| Runtime | Status | Notes |
|---|---|---|
| Perry (AOT → native) | supported | Same source, compiled via LLVM. No JS runtime at execution time. |
| Node.js ≥ 22 | supported | Uses node:net, node:tls, node:crypto, Buffer. |
| Bun ≥ 1.3 | supported | Fully works; TLS-from-SYN bypasses Bun's known mid-stream upgrade quirk. |
// Object form
const conn = await connect({
host: '127.0.0.1', port: 6379,
username: 'alice', password: 'acltestpw', // ACL — both required
database: 0,
name: 'tusk-client-1', // CLIENT SETNAME via HELLO
});
// URL form (via `resolveConnectOptions`)
import { resolveConnectOptions } from '@perryts/redis';
const conn = await connect(resolveConnectOptions({
url: 'redis://alice:acltestpw@db.internal:6379/0?name=tusk-client-1',
}));
// TLS
const conn = await connect(resolveConnectOptions({
url: 'rediss://db.internal:6380?tls=verify-full',
}));import { Pool } from '@perryts/redis';
const pool = new Pool({ host: '127.0.0.1', port: 6379, max: 10 });
// Acquire + run + release in one call:
const r = await pool.command('GET', 'key');
// Or manually:
await pool.withConnection(async (conn) => {
await conn.command('MULTI');
await conn.command('SET', 'a', '1');
await conn.command('EXEC');
});
await pool.close();const p = conn.pipeline();
for (let i = 0; i < 1000; i++) p.add('SET', 'k' + i, String(i));
const replies = await p.exec();
// replies[i] = { ok: true, value: CommandReply } | { ok: false, error: RedisError }await conn.subscribe('news', (msg) => {
console.log(msg.channel, msg.payload.toString('utf8'));
});const m = conn.multi();
m.add('SET', 'a', '1');
m.add('SET', 'b', '2');
const out = await m.exec();
// out[i] = { ok: true, value: CommandReply } | { ok: false, error } | { aborted: true }import { evalScript, evalShaScript, sha1Hex } from '@perryts/redis';
const sha1 = sha1Hex("return tonumber(ARGV[1]) * 2");
await evalScript(conn, "return tonumber(ARGV[1]) * 2", [], ['21']);
await evalShaScript(conn, sha1, [], ['21']); // auto-retries on -NOSCRIPTimport { ClusterClient } from '@perryts/redis';
const cluster = new ClusterClient({
nodes: [
{ host: 'cluster1', port: 7001 },
{ host: 'cluster2', port: 7002 },
{ host: 'cluster3', port: 7003 },
],
password: 'secret',
});
await cluster.command('SET', 'user:{1000}.profile', JSON.stringify({...}));bun install
bun test # unit + mock-server integration tests
bun run typecheck # tsc --noEmit
bun run build # tsc → dist/
# Live-server tests (requires a running Redis):
redis-server --port 36379 --daemonize yes
REDIS_HOST=127.0.0.1 REDIS_PORT=36379 bun test tests/integration/real-server.test.tsMIT — see LICENSE.