diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 4b93340ad8..0c4c0c1ca8 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -1,24 +1,25 @@ # `createClient` configuration -| Property | Default | Description | -|--------------------------|------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| -| socket | | Object defining socket connection properties | -| socket.url | | `[redis[s]:]//[[username][:password]@][host][:port]` | -| socket.host | `'localhost'` | Hostname to connect to | -| socket.port | `6379` | Port to connect to | -| socket.username | | ACL username ([see ACL guide](https://redis.io/topics/acl)) | -| socket.password | | ACL password or the old "--requirepass" password | -| socket.connectTimeout | `5000` | The timeout for connecting to the Redis Server (in milliseconds) | -| socket.noDelay | `true` | Enable/disable the use of [`Nagle's algorithm`](https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay) | -| socket.keepAlive | `5000` | Enable/disable the [`keep-alive`](https://nodejs.org/api/net.html#net_socket_setkeepalive_enable_initialdelay) functionality | -| socket.tls | | Set to `true` to enable [TLS Configuration](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback) | -| socket.reconnectStrategy | `retries => Math.min(retries * 50, 500)` | A function containing the [Reconnect Strategy](#reconnect-strategy) logic | -| modules | | Object defining which [Redis Modules](https://redis.io/modules) to include (TODO - document) | -| scripts | | Object defining Lua scripts to use with this client. See [Lua Scripts](../README.md#lua-scripts) | -| commandsQueueMaxLength | | Maximum length of the client's internal command queue | -| readonly | `false` | Connect in [`READONLY`](https://redis.io/commands/readonly) mode | -| legacyMode | `false` | Maintain some backwards compatibility (see the [Migration Guide](v3-to-v4.md)) | -| isolationPoolOptions | | See the [Isolated Execution Guide](./isolated-execution.md) | +| Property | Default | Description | +|--------------------------|------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| url | | `redis[s]://[[username][:password]@][host][:port][/db-number]` (see [`redis`](https://www.iana.org/assignments/uri-schemes/prov/redis) and [`rediss`](https://www.iana.org/assignments/uri-schemes/prov/rediss) IANA registration for more details) | +| socket | | Object defining socket connection properties | +| socket.host | `'localhost'` | Hostname to connect to | +| socket.port | `6379` | Port to connect to | +| socket.connectTimeout | `5000` | The timeout for connecting to the Redis Server (in milliseconds) | +| socket.noDelay | `true` | Enable/disable the use of [`Nagle's algorithm`](https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay) | +| socket.keepAlive | `5000` | Enable/disable the [`keep-alive`](https://nodejs.org/api/net.html#net_socket_setkeepalive_enable_initialdelay) functionality | +| socket.tls | | Set to `true` to enable [TLS Configuration](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback) | +| socket.reconnectStrategy | `retries => Math.min(retries * 50, 500)` | A function containing the [Reconnect Strategy](#reconnect-strategy) logic | +| username | | ACL username ([see ACL guide](https://redis.io/topics/acl)) | +| password | | ACL password or the old "--requirepass" password | +| database | | Database number to connect to (see [`SELECT`](https://redis.io/commands/select) command) | +| modules | | Object defining which [Redis Modules](https://redis.io/modules) to include (TODO - document) | +| scripts | | Object defining Lua Scripts to use with this client (see [Lua Scripts](../README.md#lua-scripts)) | +| commandsQueueMaxLength | | Maximum length of the client's internal command queue | +| readonly | `false` | Connect in [`READONLY`](https://redis.io/commands/readonly) mode | +| legacyMode | `false` | Maintain some backwards compatibility (see the [Migration Guide](v3-to-v4.md)) | +| isolationPoolOptions | | See the [Isolated Execution Guide](./isolated-execution.md) | ## Reconnect Strategy diff --git a/lib/client.spec.ts b/lib/client.spec.ts index 7f1a534352..06f8d2bb10 100644 --- a/lib/client.spec.ts +++ b/lib/client.spec.ts @@ -18,6 +18,53 @@ export const SQUARE_SCRIPT = defineScript({ }); describe('Client', () => { + describe('parseURL', () => { + it('redis://user:secret@localhost:6379/0', () => { + assert.deepEqual( + RedisClient.parseURL('redis://user:secret@localhost:6379/0'), + { + socket: { + host: 'localhost', + port: 6379 + }, + username: 'user', + password: 'secret', + database: 0 + } + ); + }); + + it('rediss://user:secret@localhost:6379/0', () => { + assert.deepEqual( + RedisClient.parseURL('rediss://user:secret@localhost:6379/0'), + { + socket: { + host: 'localhost', + port: 6379, + tls: true + }, + username: 'user', + password: 'secret', + database: 0 + } + ); + }); + + it('Invalid protocol', () => { + assert.throws( + () => RedisClient.parseURL('redi://user:secret@localhost:6379/0'), + TypeError + ); + }); + + it('Invalid pathname', () => { + assert.throws( + () => RedisClient.parseURL('redis://user:secret@localhost:6379/NaN'), + TypeError + ); + }); + }); + describe('authentication', () => { itWithClient(TestRedisServers.PASSWORD, 'Client should be authenticated', async client => { assert.equal( @@ -28,10 +75,8 @@ describe('Client', () => { it('should not retry connecting if failed due to wrong auth', async () => { const client = RedisClient.create({ - socket: { - ...TEST_REDIS_SERVERS[TestRedisServers.PASSWORD], - password: 'wrongpassword' - } + ...TEST_REDIS_SERVERS[TestRedisServers.PASSWORD], + password: 'wrongpassword' }); await assert.rejects( @@ -49,7 +94,7 @@ describe('Client', () => { describe('legacyMode', () => { const client = RedisClient.create({ - socket: TEST_REDIS_SERVERS[TestRedisServers.OPEN], + ...TEST_REDIS_SERVERS[TestRedisServers.OPEN], scripts: { square: SQUARE_SCRIPT }, @@ -173,9 +218,7 @@ describe('Client', () => { describe('events', () => { it('connect, ready, end', async () => { - const client = RedisClient.create({ - socket: TEST_REDIS_SERVERS[TestRedisServers.OPEN] - }); + const client = RedisClient.create(TEST_REDIS_SERVERS[TestRedisServers.OPEN]); await Promise.all([ client.connect(), @@ -550,9 +593,7 @@ describe('Client', () => { }); it('client.quit', async () => { - const client = RedisClient.create({ - socket: TEST_REDIS_SERVERS[TestRedisServers.OPEN] - }); + const client = RedisClient.create(TEST_REDIS_SERVERS[TestRedisServers.OPEN]); await client.connect(); diff --git a/lib/client.ts b/lib/client.ts index c9e9cecf92..93afee1ff1 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -1,4 +1,4 @@ -import RedisSocket, { RedisSocketOptions } from './socket'; +import RedisSocket, { RedisSocketOptions, RedisNetSocketOptions, RedisTlsSocketOptions } from './socket'; import RedisCommandsQueue, { PubSubListener, PubSubSubscribeCommands, PubSubUnsubscribeCommands, QueueCommandOptions } from './commands-queue'; import COMMANDS, { TransformArgumentsReply } from './commands'; import { RedisCommand, RedisModules, RedisReply } from './commands'; @@ -12,9 +12,14 @@ import { HScanTuple } from './commands/HSCAN'; import { encodeCommand, extendWithDefaultCommands, extendWithModulesAndScripts, transformCommandArguments } from './commander'; import { Pool, Options as PoolOptions, createPool } from 'generic-pool'; import { ClientClosedError } from './errors'; +import { URL } from 'url'; export interface RedisClientOptions { + url?: string; socket?: RedisSocketOptions; + username?: string; + password?: string; + database?: number; modules?: M; scripts?: S; commandsQueueMaxLength?: number; @@ -71,6 +76,45 @@ export default class RedisClient { + // https://www.iana.org/assignments/uri-schemes/prov/redis + const { hostname, port, protocol, username, password, pathname } = new URL(url), + parsed: RedisClientOptions<{}, {}> = { + socket: { + host: hostname + } + }; + + if (protocol === 'rediss:') { + (parsed.socket as RedisTlsSocketOptions).tls = true; + } else if (protocol !== 'redis:') { + throw new TypeError('Invalid protocol'); + } + + if (port) { + (parsed.socket as RedisNetSocketOptions).port = Number(port); + } + + if (username) { + parsed.username = username; + } + + if (password) { + parsed.password = password; + } + + if (pathname.length > 1) { + const database = Number(pathname.substring(1)); + if (isNaN(database)) { + throw new TypeError('Invalid pathname'); + } + + parsed.database = database; + } + + return parsed; + } + readonly #options?: RedisClientOptions; readonly #socket: RedisSocket; readonly #queue: RedisCommandsQueue; @@ -96,7 +140,7 @@ export default class RedisClient) { super(); - this.#options = options; + this.#options = this.#initiateOptions(options); this.#socket = this.#initiateSocket(); this.#queue = this.#initiateQueue(); this.#isolationPool = createPool({ @@ -110,6 +154,23 @@ export default class RedisClient): RedisClientOptions | undefined { + if (options?.url) { + const parsed = RedisClient.parseURL(options.url); + if (options.socket) { + parsed.socket = Object.assign(options.socket, parsed.socket); + } + + Object.assign(options, parsed); + } + + if (options?.database) { + this.#selectedDB = options.database; + } + + return options; + } + #initiateSocket(): RedisSocket { const socketInitiator = async (): Promise => { const v4Commands = this.#options?.legacyMode ? this.#v4 : this, @@ -123,8 +184,8 @@ export default class RedisClient { connectEvent: string; @@ -44,14 +38,6 @@ export default class RedisSocket extends EventEmitter { static #initiateOptions(options?: RedisSocketOptions): RedisSocketOptions { options ??= {}; if (!RedisSocket.#isUnixSocket(options)) { - if (RedisSocket.#isUrlSocket(options)) { - const url = new URL(options.url); - (options as RedisNetSocketOptions).port = Number(url.port); - (options as RedisNetSocketOptions).host = url.hostname; - options.username = url.username; - options.password = url.password; - } - (options as RedisNetSocketOptions).port ??= 6379; (options as RedisNetSocketOptions).host ??= '127.0.0.1'; } @@ -67,10 +53,6 @@ export default class RedisSocket extends EventEmitter { return Math.min(retries * 50, 500); } - static #isUrlSocket(options: RedisSocketOptions): options is RedisUrlSocketOptions { - return Object.prototype.hasOwnProperty.call(options, 'url'); - } - static #isUnixSocket(options: RedisSocketOptions): options is RedisUnixSocketOptions { return Object.prototype.hasOwnProperty.call(options, 'path'); } diff --git a/lib/test-utils.ts b/lib/test-utils.ts index 8468592369..713a1a3434 100644 --- a/lib/test-utils.ts +++ b/lib/test-utils.ts @@ -1,5 +1,5 @@ import { strict as assert } from 'assert'; -import RedisClient, { RedisClientType } from './client'; +import RedisClient, { RedisClientOptions, RedisClientType } from './client'; import { execSync, spawn } from 'child_process'; import { once } from 'events'; import { RedisSocketOptions } from './socket'; @@ -9,6 +9,8 @@ import RedisCluster, { RedisClusterType } from './cluster'; import { promises as fs } from 'fs'; import { Context as MochaContext } from 'mocha'; import { promiseTimeout } from './utils'; +import { RedisModules } from './commands'; +import { RedisLuaScripts } from './lua-script'; type RedisVersion = [major: number, minor: number, patch: number]; @@ -52,7 +54,7 @@ export enum TestRedisServers { PASSWORD } -export const TEST_REDIS_SERVERS: Record = {}; +export const TEST_REDIS_SERVERS: Record> = {}; export enum TestRedisClusters { OPEN @@ -226,13 +228,17 @@ export async function spawnGlobalRedisCluster(type: TestRedisClusters | null, nu async function spawnOpenServer(): Promise { TEST_REDIS_SERVERS[TestRedisServers.OPEN] = { - port: await spawnGlobalRedisServer() + socket: { + port: await spawnGlobalRedisServer() + } }; } async function spawnPasswordServer(): Promise { TEST_REDIS_SERVERS[TestRedisServers.PASSWORD] = { - port: await spawnGlobalRedisServer(['--requirepass', 'password']), + socket: { + port: await spawnGlobalRedisServer(['--requirepass', 'password']), + }, password: 'password' }; @@ -285,9 +291,7 @@ export function itWithClient( it(title, async function () { if (handleMinimumRedisVersion(this, options?.minimumRedisVersion)) return; - const client = RedisClient.create({ - socket: TEST_REDIS_SERVERS[type] - }); + const client = RedisClient.create(TEST_REDIS_SERVERS[type]); await client.connect();