From d05db42da5d9330db7cff7c9320607e0e935721f Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Fri, 12 Sep 2025 19:00:05 +0300 Subject: [PATCH 1/4] feat: support client setinfo --- lib/redis/RedisOptions.ts | 16 +++ lib/redis/event_handler.ts | 16 +++ test/functional/client_info.ts | 201 +++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 test/functional/client_info.ts diff --git a/lib/redis/RedisOptions.ts b/lib/redis/RedisOptions.ts index a397c538e..a7aabe06e 100644 --- a/lib/redis/RedisOptions.ts +++ b/lib/redis/RedisOptions.ts @@ -44,6 +44,20 @@ export interface CommonRedisOptions extends CommanderOptions { */ connectionName?: string; + /** + * If true, skips setting library info via CLIENT SETINFO. + * @link https://redis.io/docs/latest/commands/client-setinfo/ + * @default false + */ + disableClientInfo?: boolean; + + /** + * Tag to append to the library name in CLIENT SETINFO (ioredis(tag)). + * @link https://redis.io/docs/latest/commands/client-setinfo/ + * @default undefined + */ + clientInfoTag?: string; + /** * If set, client will send AUTH command with the value of this option as the first argument when connected. * This is supported since Redis 6. @@ -208,6 +222,8 @@ export const DEFAULT_REDIS_OPTIONS: RedisOptions = { keepAlive: 0, noDelay: true, connectionName: null, + disableClientInfo: false, + clientInfoTag: undefined, // Sentinel sentinels: null, name: null, diff --git a/lib/redis/event_handler.ts b/lib/redis/event_handler.ts index 7625719c9..84f96d2a5 100644 --- a/lib/redis/event_handler.ts +++ b/lib/redis/event_handler.ts @@ -7,6 +7,7 @@ import { MaxRetriesPerRequestError } from "../errors"; import { CommandItem, Respondable } from "../types"; import { Debug, noop, CONNECTION_CLOSED_ERROR_MSG } from "../utils"; import DataHandler from "../DataHandler"; +import { version } from "../../package.json"; const debug = Debug("connection"); @@ -264,6 +265,21 @@ export function readyHandler(self) { self.client("setname", self.options.connectionName).catch(noop); } + if (!self.options?.disableClientInfo) { + debug("set the client info"); + self.client("SETINFO", "LIB-VER", version).catch(noop); + + self + .client( + "SETINFO", + "LIB-NAME", + self.options?.clientInfoTag + ? `ioredis(${self.options.clientInfoTag})` + : "ioredis" + ) + .catch(noop); + } + if (self.options.readOnly) { debug("set the connection to readonly mode"); self.readonly().catch(noop); diff --git a/test/functional/client_info.ts b/test/functional/client_info.ts new file mode 100644 index 000000000..9e6f7f5ab --- /dev/null +++ b/test/functional/client_info.ts @@ -0,0 +1,201 @@ +import { expect } from "chai"; +import Redis, { Cluster } from "../../lib"; +import MockServer from "../helpers/mock_server"; + +describe("clientInfo", function () { + describe("Redis", function () { + let redis: Redis; + let mockServer: MockServer; + let clientInfoCommands: Array<{ key: string; value: string }>; + + beforeEach(() => { + clientInfoCommands = []; + mockServer = new MockServer(30001, (argv) => { + if ( + argv[0].toLowerCase() === "client" && + argv[1].toLowerCase() === "setinfo" + ) { + clientInfoCommands.push({ + key: argv[2], + value: argv[3], + }); + } + }); + }); + + afterEach(() => { + mockServer.disconnect(); + + if (redis && redis.status !== "end") { + redis.disconnect(); + } + }); + + it("should send client info by default", async () => { + redis = new Redis({ port: 30001 }); + + // Wait for the client info to be sent, as it happens after the ready event + await redis.ping(); + + expect(clientInfoCommands).to.have.length(2); + + const libVerCommand = clientInfoCommands.find( + (cmd) => cmd.key === "LIB-VER" + ); + const libNameCommand = clientInfoCommands.find( + (cmd) => cmd.key === "LIB-NAME" + ); + + expect(libVerCommand).to.exist; // version will change over time + expect(libNameCommand).to.exist; + expect(libNameCommand?.value).to.equal("ioredis"); + }); + + it("should not send client info when disableClientInfo is true", async () => { + redis = new Redis({ port: 30001, disableClientInfo: true }); + + // Wait for the client info to be sent, as it happens after the ready event + await redis.ping(); + + expect(clientInfoCommands).to.have.length(0); + }); + + it("should append tag to library name when clientInfoTag is set", async () => { + redis = new Redis({ port: 30001, clientInfoTag: "tag-test" }); + + // Wait for the client info to be sent, as it happens after the ready event + await redis.ping(); + + expect(clientInfoCommands).to.have.length(2); + + const libNameCommand = clientInfoCommands.find( + (cmd) => cmd.key === "LIB-NAME" + ); + expect(libNameCommand).to.exist; + expect(libNameCommand?.value).to.equal("ioredis(tag-test)"); + }); + + it("should send client info after reconnection", async () => { + redis = new Redis({ port: 30001 }); + + // Wait for the client info to be sent, as it happens after the ready event + await redis.ping(); + redis.disconnect(); + + // Make sure the client is disconnected + await new Promise((resolve) => { + redis.once("end", () => { + resolve(); + }); + }); + + await redis.connect(); + await redis.ping(); + + expect(clientInfoCommands).to.have.length(4); + }); + }); + + describe("Error handling", () => { + let mockServer: MockServer; + let redis: Redis; + + afterEach(() => { + mockServer.disconnect(); + redis.disconnect(); + }); + + it("should handle server that doesn't support CLIENT SETINFO", async () => { + mockServer = new MockServer(30002, (argv) => { + if ( + argv[0].toLowerCase() === "client" && + argv[1].toLowerCase() === "setinfo" + ) { + // Simulate older Redis version that doesn't support SETINFO + return new Error("ERR unknown subcommand 'SETINFO'"); + } + }); + + redis = new Redis({ port: 30002 }); + await redis.ping(); + + expect(redis.status).to.equal("ready"); + }); + }); + + describe("Cluster", () => { + let cluster: Cluster; + let mockServers: MockServer[]; + let clientInfoCommands: Array<{ key: string; value: string }>; + const slotTable = [ + [0, 5000, ["127.0.0.1", 30001]], + [5001, 9999, ["127.0.0.1", 30002]], + [10000, 16383, ["127.0.0.1", 30003]], + ]; + + beforeEach(() => { + clientInfoCommands = []; + + // Create mock server that handles both cluster commands and client info + const handler = (argv) => { + if (argv[0] === "cluster" && argv[1] === "SLOTS") { + return slotTable; + } + if ( + argv[0].toLowerCase() === "client" && + argv[1].toLowerCase() === "setinfo" + ) { + clientInfoCommands.push({ + key: argv[2], + value: argv[3], + }); + } + }; + + mockServers = [ + new MockServer(30001, handler), + new MockServer(30002, handler), + new MockServer(30003, handler), + ]; + }); + + afterEach(() => { + mockServers.forEach((server) => server.disconnect()); + if (cluster) { + cluster.disconnect(); + } + }); + + it("should send client info by default", async () => { + cluster = new Redis.Cluster([{ host: "127.0.0.1", port: 30001 }]); + + // Wait for cluster to be ready and send a command to ensure connection + await cluster.ping(); + + // Should have sent 2 SETINFO commands (LIB-VER and LIB-NAME) + expect(clientInfoCommands).to.have.length.at.least(2); + + const libVerCommand = clientInfoCommands.find( + (cmd) => cmd.key === "LIB-VER" + ); + const libNameCommand = clientInfoCommands.find( + (cmd) => cmd.key === "LIB-NAME" + ); + + expect(libVerCommand).to.exist; + expect(libNameCommand).to.exist; + expect(libNameCommand?.value).to.equal("ioredis"); + }); + + it("should propagate disableClientInfo to child nodes", async () => { + cluster = new Redis.Cluster([{ host: "127.0.0.1", port: 30001 }], { + redisOptions: { + disableClientInfo: true, + }, + }); + await cluster.ping(); + + expect(clientInfoCommands).to.have.length(0); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4d4156957..45ae4fd0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ ], "moduleResolution": "node", "module": "commonjs", - "outDir": "./built" + "outDir": "./built", + "resolveJsonModule": true, }, "include": ["./lib/**/*"] } From 56e60fbbe2ee1efaa155fbc67883025264c96c1f Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Fri, 12 Sep 2025 19:26:33 +0300 Subject: [PATCH 2/4] fix: use require instead of import for package.json version --- lib/redis/event_handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/redis/event_handler.ts b/lib/redis/event_handler.ts index 84f96d2a5..a781f2d21 100644 --- a/lib/redis/event_handler.ts +++ b/lib/redis/event_handler.ts @@ -7,7 +7,8 @@ import { MaxRetriesPerRequestError } from "../errors"; import { CommandItem, Respondable } from "../types"; import { Debug, noop, CONNECTION_CLOSED_ERROR_MSG } from "../utils"; import DataHandler from "../DataHandler"; -import { version } from "../../package.json"; +// This seems to be less problematic, than import +const { version } = require("../../package.json"); const debug = Debug("connection"); From cbc8e25df29c570f43ba5471049b04dbd02b160f Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Thu, 18 Sep 2025 16:11:58 +0300 Subject: [PATCH 3/4] fix: replace synchronous package.json require with async getPackageMeta utility --- lib/redis/event_handler.ts | 23 +++++++++++++++++---- lib/utils/index.ts | 37 ++++++++++++++++++++++++++++++++++ test/functional/client_info.ts | 6 +++++- tsconfig.json | 3 +-- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/lib/redis/event_handler.ts b/lib/redis/event_handler.ts index a781f2d21..c9002520e 100644 --- a/lib/redis/event_handler.ts +++ b/lib/redis/event_handler.ts @@ -5,10 +5,13 @@ import { AbortError } from "redis-errors"; import Command from "../Command"; import { MaxRetriesPerRequestError } from "../errors"; import { CommandItem, Respondable } from "../types"; -import { Debug, noop, CONNECTION_CLOSED_ERROR_MSG } from "../utils"; +import { + Debug, + noop, + CONNECTION_CLOSED_ERROR_MSG, + getPackageMeta, +} from "../utils"; import DataHandler from "../DataHandler"; -// This seems to be less problematic, than import -const { version } = require("../../package.json"); const debug = Debug("connection"); @@ -268,7 +271,19 @@ export function readyHandler(self) { if (!self.options?.disableClientInfo) { debug("set the client info"); - self.client("SETINFO", "LIB-VER", version).catch(noop); + + let version = null; + + getPackageMeta() + .then((packageMeta) => { + version = packageMeta?.version; + }) + .catch(noop) + .finally(() => { + self + .client("SETINFO", "LIB-VER", version ?? "error-fetching-version") + .catch(noop); + }); self .client( diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 46bf21619..a6096be41 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -1,3 +1,5 @@ +import { promises as fsPromises } from "fs"; +import { resolve } from "path"; import { parse as urllibParse } from "url"; import { defaults, noop } from "./lodash"; import { Callback } from "../types"; @@ -319,4 +321,39 @@ export function zipMap(keys: K[], values: V[]): Map { return map; } +/** + * Memoized package metadata to avoid repeated file system reads. + * + * @internal + */ +let cachedPackageMeta: { version: string } = null; + +/** + * Retrieves cached package metadata from package.json. + * + * @internal + * @returns {Promise<{version: string} | null>} Package metadata or null if unavailable + */ +export async function getPackageMeta() { + if (cachedPackageMeta) { + return cachedPackageMeta; + } + + try { + const filePath = resolve(__dirname, "..", "..", "package.json"); + const data = await fsPromises.readFile(filePath, "utf8"); + const parsed = JSON.parse(data); + + cachedPackageMeta = { + version: parsed.version, + }; + } catch (err) { + cachedPackageMeta = { + version: "error-fetching-version", + }; + } finally { + return cachedPackageMeta; + } +} + export { Debug, defaults, noop }; diff --git a/test/functional/client_info.ts b/test/functional/client_info.ts index 9e6f7f5ab..ca921d60c 100644 --- a/test/functional/client_info.ts +++ b/test/functional/client_info.ts @@ -46,7 +46,9 @@ describe("clientInfo", function () { (cmd) => cmd.key === "LIB-NAME" ); - expect(libVerCommand).to.exist; // version will change over time + expect(libVerCommand).to.exist; + expect(libVerCommand?.value).to.be.a("string"); + expect(libVerCommand?.value).to.not.equal("unknown"); expect(libNameCommand).to.exist; expect(libNameCommand?.value).to.equal("ioredis"); }); @@ -183,6 +185,8 @@ describe("clientInfo", function () { ); expect(libVerCommand).to.exist; + expect(libVerCommand?.value).to.be.a("string"); + expect(libVerCommand?.value).to.not.equal("unknown"); expect(libNameCommand).to.exist; expect(libNameCommand?.value).to.equal("ioredis"); }); diff --git a/tsconfig.json b/tsconfig.json index 45ae4fd0f..4d4156957 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,7 @@ ], "moduleResolution": "node", "module": "commonjs", - "outDir": "./built", - "resolveJsonModule": true, + "outDir": "./built" }, "include": ["./lib/**/*"] } From 6d39886b426705259372e844d5a4d4bad3e3f183 Mon Sep 17 00:00:00 2001 From: Pavel Pashov Date: Thu, 18 Sep 2025 17:30:31 +0300 Subject: [PATCH 4/4] refactor: unsafe usage of ReturnStatement --- lib/redis/event_handler.ts | 2 +- lib/utils/index.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/redis/event_handler.ts b/lib/redis/event_handler.ts index c9002520e..13d4c78a5 100644 --- a/lib/redis/event_handler.ts +++ b/lib/redis/event_handler.ts @@ -281,7 +281,7 @@ export function readyHandler(self) { .catch(noop) .finally(() => { self - .client("SETINFO", "LIB-VER", version ?? "error-fetching-version") + .client("SETINFO", "LIB-VER", version) .catch(noop); }); diff --git a/lib/utils/index.ts b/lib/utils/index.ts index a6096be41..c6a98f34f 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -347,11 +347,13 @@ export async function getPackageMeta() { cachedPackageMeta = { version: parsed.version, }; + + return cachedPackageMeta; } catch (err) { cachedPackageMeta = { version: "error-fetching-version", }; - } finally { + return cachedPackageMeta; } }