Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions lib/redis/RedisOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion lib/redis/event_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ 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";

const debug = Debug("connection");
Expand Down Expand Up @@ -264,6 +269,33 @@ export function readyHandler(self) {
self.client("setname", self.options.connectionName).catch(noop);
}

if (!self.options?.disableClientInfo) {
debug("set the client info");

let version = null;

getPackageMeta()
.then((packageMeta) => {
version = packageMeta?.version;
})
.catch(noop)
.finally(() => {
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);
Expand Down
39 changes: 39 additions & 0 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -319,4 +321,41 @@ export function zipMap<K, V>(keys: K[], values: V[]): Map<K, V> {
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,
};

return cachedPackageMeta;
} catch (err) {
cachedPackageMeta = {
version: "error-fetching-version",
};

return cachedPackageMeta;
}
}

export { Debug, defaults, noop };
205 changes: 205 additions & 0 deletions test/functional/client_info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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;
expect(libVerCommand?.value).to.be.a("string");
expect(libVerCommand?.value).to.not.equal("unknown");
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<void>((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(libVerCommand?.value).to.be.a("string");
expect(libVerCommand?.value).to.not.equal("unknown");
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);
});
});
});
Loading