diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index f3e72a3a17..98b51c3b22 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -4,11 +4,11 @@ import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsP import RedisCommandsQueue, { CommandOptions } from './commands-queue'; import { EventEmitter } from 'node:events'; import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander'; -import { ClientClosedError, ClientOfflineError, DisconnectsClientError, WatchError } from '../errors'; +import { ClientClosedError, ClientOfflineError, DisconnectsClientError, SimpleError, WatchError } from '../errors'; import { URL } from 'node:url'; import { TcpSocketConnectOpts } from 'node:net'; import { PUBSUB_TYPE, PubSubType, PubSubListener, PubSubTypeListeners, ChannelListeners } from './pub-sub'; -import { Command, CommandSignature, TypeMapping, CommanderConfig, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, RedisArgument, ReplyWithTypeMapping, SimpleStringReply, TransformReply } from '../RESP/types'; +import { Command, CommandSignature, TypeMapping, CommanderConfig, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, RedisArgument, ReplyWithTypeMapping, SimpleStringReply, TransformReply, CommandArguments } from '../RESP/types'; import RedisClientMultiCommand, { RedisClientMultiCommandType } from './multi-command'; import { RedisMultiQueuedCommand } from '../multi-command'; import HELLO, { HelloOptions } from '../commands/HELLO'; @@ -18,6 +18,7 @@ import { RedisPoolOptions, RedisClientPool } from './pool'; import { RedisVariadicArgument, parseArgs, pushVariadicArguments } from '../commands/generic-transformers'; import { BasicCommandParser, CommandParser } from './parser'; import SingleEntryCache from '../single-entry-cache'; +import { version } from '../../package.json' export interface RedisClientOptions< M extends RedisModules = RedisModules, @@ -81,6 +82,14 @@ export interface RedisClientOptions< * TODO */ commandOptions?: CommandOptions; + /** + * If set to true, disables sending client identifier (user-agent like message) to the redis server + */ + disableClientInfo?: boolean; + /** + * Tag to append to library name that is sent to the Redis server + */ + clientInfoTag?: string; } type WithCommands< @@ -437,7 +446,30 @@ export default class RedisClient< }); } - async #handshake(selectedDB: number) { + async #handshake(chainId: symbol, asap: boolean) { + const promises = []; + const commandsWithErrorHandlers = await this.#getHandshakeCommands(this.#selectedDB ?? 0); + + if (asap) commandsWithErrorHandlers.reverse() + + for (const { cmd, errorHandler } of commandsWithErrorHandlers) { + promises.push( + this.#queue + .addCommand(cmd, { + chainId, + asap + }) + .catch(errorHandler) + ); + } + return promises; + } + + async #getHandshakeCommands( + selectedDB: number + ): Promise< + Array<{ cmd: CommandArguments } & { errorHandler?: (err: Error) => void }> + > { const commands = []; const cp = this.#options?.credentialsProvider; @@ -455,8 +487,8 @@ export default class RedisClient< } if (cp && cp.type === 'streaming-credentials-provider') { - - const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp) + const [credentials, disposable] = + await this.#subscribeForStreamingCredentials(cp); this.#credentialsSubscription = disposable; if (credentials.password) { @@ -471,55 +503,84 @@ export default class RedisClient< hello.SETNAME = this.#options.name; } - commands.push( - parseArgs(HELLO, this.#options.RESP, hello) - ); + commands.push({ cmd: parseArgs(HELLO, this.#options.RESP, hello) }); } else { - if (cp && cp.type === 'async-credentials-provider') { - const credentials = await cp.credentials(); if (credentials.username || credentials.password) { - commands.push( - parseArgs(COMMANDS.AUTH, { + commands.push({ + cmd: parseArgs(COMMANDS.AUTH, { username: credentials.username, password: credentials.password ?? '' }) - ); + }); } } if (cp && cp.type === 'streaming-credentials-provider') { - - const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp) + const [credentials, disposable] = + await this.#subscribeForStreamingCredentials(cp); this.#credentialsSubscription = disposable; if (credentials.username || credentials.password) { - commands.push( - parseArgs(COMMANDS.AUTH, { + commands.push({ + cmd: parseArgs(COMMANDS.AUTH, { username: credentials.username, password: credentials.password ?? '' }) - ); + }); } } if (this.#options?.name) { - commands.push( - parseArgs(COMMANDS.CLIENT_SETNAME, this.#options.name) - ); + commands.push({ + cmd: parseArgs(COMMANDS.CLIENT_SETNAME, this.#options.name) + }); } } if (selectedDB !== 0) { - commands.push(['SELECT', this.#selectedDB.toString()]); + commands.push({ cmd: ['SELECT', this.#selectedDB.toString()] }); } if (this.#options?.readonly) { - commands.push( - parseArgs(COMMANDS.READONLY) - ); + commands.push({ cmd: parseArgs(COMMANDS.READONLY) }); + } + + if (!this.#options?.disableClientInfo) { + commands.push({ + cmd: ['CLIENT', 'SETINFO', 'LIB-VER', version], + errorHandler: (err: Error) => { + // Only throw if not a SimpleError - unknown subcommand + // Client libraries are expected to ignore failures + // of type SimpleError - unknown subcommand, which are + // expected from older servers ( < v7 ) + if (!(err instanceof SimpleError) || !err.isUnknownSubcommand()) { + throw err; + } + } + }); + + commands.push({ + cmd: [ + 'CLIENT', + 'SETINFO', + 'LIB-NAME', + this.#options?.clientInfoTag + ? `node-redis(${this.#options.clientInfoTag})` + : 'node-redis' + ], + errorHandler: (err: Error) => { + // Only throw if not a SimpleError - unknown subcommand + // Client libraries are expected to ignore failures + // of type SimpleError - unknown subcommand, which are + // expected from older servers ( < v7 ) + if (!(err instanceof SimpleError) || !err.isUnknownSubcommand()) { + throw err; + } + } + }); } return commands; @@ -548,15 +609,7 @@ export default class RedisClient< ); } - const commands = await this.#handshake(this.#selectedDB); - for (let i = commands.length - 1; i >= 0; --i) { - promises.push( - this.#queue.addCommand(commands[i], { - chainId, - asap: true - }) - ); - } + promises.push(...(await this.#handshake(chainId, true))); if (promises.length) { this.#write(); @@ -1133,13 +1186,7 @@ export default class RedisClient< selectedDB = this._self.#options?.database ?? 0; this._self.#credentialsSubscription?.dispose(); this._self.#credentialsSubscription = null; - for (const command of (await this._self.#handshake(selectedDB))) { - promises.push( - this._self.#queue.addCommand(command, { - chainId - }) - ); - } + promises.push(...(await this._self.#handshake(chainId, false))); this._self.#scheduleWrite(); await Promise.all(promises); this._self.#selectedDB = selectedDB; diff --git a/packages/client/lib/commands/CLIENT_INFO.spec.ts b/packages/client/lib/commands/CLIENT_INFO.spec.ts index 50345a46ce..96881e6c1a 100644 --- a/packages/client/lib/commands/CLIENT_INFO.spec.ts +++ b/packages/client/lib/commands/CLIENT_INFO.spec.ts @@ -2,6 +2,7 @@ import { strict as assert } from 'node:assert'; import CLIENT_INFO from './CLIENT_INFO'; import testUtils, { GLOBAL } from '../test-utils'; import { parseArgs } from './generic-transformers'; +import { version } from '../../package.json'; describe('CLIENT INFO', () => { testUtils.isVersionGreaterThanHook([6, 2]); @@ -48,4 +49,89 @@ describe('CLIENT INFO', () => { } } }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.clientInfo Redis < 7', async client => { + const reply = await client.clientInfo(); + if (!testUtils.isVersionGreaterThan([7])) { + assert.strictEqual(reply.libName, undefined, 'LibName should be undefined for Redis < 7'); + assert.strictEqual(reply.libVer, undefined, 'LibVer should be undefined for Redis < 7'); + } + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[7], 'LATEST'], 'client.clientInfo Redis>=7 info disabled', async client => { + const reply = await client.clientInfo(); + assert.equal(reply.libName, ''); + assert.equal(reply.libVer, ''); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + disableClientInfo: true + } + }); + + testUtils.testWithClientIfVersionWithinRange([[7], 'LATEST'], 'client.clientInfo Redis>=7 resp unset, info enabled, tag set', async client => { + const reply = await client.clientInfo(); + assert.equal(reply.libName, 'node-redis(client1)'); + assert.equal(reply.libVer, version); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + clientInfoTag: 'client1' + } + }); + + testUtils.testWithClientIfVersionWithinRange([[7], 'LATEST'], 'client.clientInfo Redis>=7 resp unset, info enabled, tag unset', async client => { + const reply = await client.clientInfo(); + assert.equal(reply.libName, 'node-redis'); + assert.equal(reply.libVer, version); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[7], 'LATEST'], 'client.clientInfo Redis>=7 resp2 info enabled', async client => { + const reply = await client.clientInfo(); + assert.equal(reply.libName, 'node-redis(client1)'); + assert.equal(reply.libVer, version); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 2, + clientInfoTag: 'client1' + } + }); + + testUtils.testWithClientIfVersionWithinRange([[7], 'LATEST'], 'client.clientInfo Redis>=7 resp2 info disabled', async client => { + const reply = await client.clientInfo(); + assert.equal(reply.libName, ''); + assert.equal(reply.libVer, ''); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + disableClientInfo: true, + RESP: 2 + } + }); + + testUtils.testWithClientIfVersionWithinRange([[7], 'LATEST'], 'client.clientInfo Redis>=7 resp3 info enabled', async client => { + const reply = await client.clientInfo(); + assert.equal(reply.libName, 'node-redis(client1)'); + assert.equal(reply.libVer, version); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3, + clientInfoTag: 'client1' + } + }); + + testUtils.testWithClientIfVersionWithinRange([[7], 'LATEST'], 'client.clientInfo Redis>=7 resp3 info disabled', async client => { + const reply = await client.clientInfo(); + assert.equal(reply.libName, ''); + assert.equal(reply.libVer, ''); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + disableClientInfo: true, + RESP: 3 + } + }); + }); diff --git a/packages/client/lib/commands/CLIENT_INFO.ts b/packages/client/lib/commands/CLIENT_INFO.ts index 36dac17544..8908bdb260 100644 --- a/packages/client/lib/commands/CLIENT_INFO.ts +++ b/packages/client/lib/commands/CLIENT_INFO.ts @@ -52,6 +52,14 @@ export interface ClientInfoReply { * available since 7.0 */ resp?: number; + /** + * available since 7.0 + */ + libName?: string; + /** + * available since 7.0 + */ + libVer?: string; } const CLIENT_INFO_REGEX = /([^\s=]+)=([^\s]*)/g; @@ -67,7 +75,6 @@ export default { for (const item of rawReply.toString().matchAll(CLIENT_INFO_REGEX)) { map[item[1]] = item[2]; } - const reply: ClientInfoReply = { id: Number(map.id), addr: map.addr, @@ -89,7 +96,9 @@ export default { totMem: Number(map['tot-mem']), events: map.events, cmd: map.cmd, - user: map.user + user: map.user, + libName: map['lib-name'], + libVer: map['lib-ver'] }; if (map.laddr !== undefined) { diff --git a/packages/client/lib/errors.ts b/packages/client/lib/errors.ts index 8af4c5e5be..74c261cc80 100644 --- a/packages/client/lib/errors.ts +++ b/packages/client/lib/errors.ts @@ -64,7 +64,11 @@ export class ErrorReply extends Error { } } -export class SimpleError extends ErrorReply {} +export class SimpleError extends ErrorReply { + isUnknownSubcommand(): boolean { + return this.message.toLowerCase().indexOf('err unknown subcommand') !== -1; + } +} export class BlobError extends ErrorReply {} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8caa47300d..b1f7b44d91 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "./dist" + "outDir": "./dist", }, "include": [ "./index.ts", - "./lib/**/*.ts" + "./lib/**/*.ts", + "./package.json" ], "exclude": [ "./lib/test-utils.ts", @@ -18,6 +19,6 @@ "./lib" ], "entryPointStrategy": "expand", - "out": "../../documentation/client" + "out": "../../documentation/client", } } diff --git a/tsconfig.base.json b/tsconfig.base.json index bd2bcac084..d4a631fc00 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,6 +15,7 @@ "sourceMap": true, "declaration": true, "declarationMap": true, - "allowJs": true + "allowJs": true, + "resolveJsonModule": true } }