diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index d520f970b0..76b2e161cf 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -173,6 +173,69 @@ describe('Client', () => { ); }); + it('unix:///tmp/redis.sock?db=2', () => { + assert.deepEqual( + RedisClient.parseURL('unix:///tmp/redis.sock?db=2'), + { + socket: { + path: '/tmp/redis.sock', + tls: false + }, + database: 2 + } + ); + }); + + it('unix://user:secret@/tmp/redis.sock?db=3', async () => { + const result = RedisClient.parseURL('unix://user:secret@/tmp/redis.sock?db=3'); + const expected: RedisClientOptions = { + socket: { + path: '/tmp/redis.sock', + tls: false + }, + username: 'user', + password: 'secret', + database: 3, + credentialsProvider: { + type: 'async-credentials-provider', + credentials: async () => ({ + username: 'user', + password: 'secret' + }) + } + }; + + const { credentialsProvider: resultCredProvider, ...resultRest } = result; + const { credentialsProvider: expectedCredProvider, ...expectedRest } = expected; + + assert.deepEqual(resultRest, expectedRest); + assert.equal(resultCredProvider?.type, expectedCredProvider?.type); + + if (resultCredProvider?.type === 'async-credentials-provider' && + expectedCredProvider?.type === 'async-credentials-provider') { + assert.deepEqual( + await resultCredProvider.credentials(), + await expectedCredProvider.credentials() + ); + } else { + assert.fail('Credentials provider type mismatch'); + } + }); + + it('Invalid unix socket database', () => { + assert.throws( + () => RedisClient.parseURL('unix:///tmp/redis.sock?db=NaN'), + TypeError + ); + }); + + it('Invalid unix socket authority', () => { + assert.throws( + () => RedisClient.parseURL('unix://localhost/tmp/redis.sock'), + TypeError + ); + }); + it('DB in URL should be parsed', async () => { const client = RedisClient.create({ url: 'redis://user:secret@localhost:6379/5' diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index c20c75830e..039aa4b288 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -255,10 +255,21 @@ export type RedisClientType< RedisClientExtensions ); -type ProxyClient = RedisClient; +type ProxyClient = RedisClient; type NamespaceProxyClient = { _self: ProxyClient }; +type RedisClientMultiCommandConstructor = new ( + ...args: ConstructorParameters +) => RedisClientMultiCommand; + +type ConfiguredRedisClientClass = { + new (options?: RedisClientOptions): ProxyClient; + prototype: ProxyClient & { + Multi?: RedisClientMultiCommandConstructor; + }; +}; + interface ScanIteratorOptions { cursor?: RedisArgument; } @@ -320,7 +331,10 @@ export default class RedisClient< } } - static #SingleEntryCache = new SingleEntryCache() + static #SingleEntryCache = new SingleEntryCache< + CommanderConfig | undefined, + ConfiguredRedisClientClass + >() static factory< M extends RedisModules = {}, @@ -340,9 +354,9 @@ export default class RedisClient< createFunctionCommand: RedisClient.#createFunctionCommand, createScriptCommand: RedisClient.#createScriptCommand, config - }); + }) as ConfiguredRedisClientClass; - Client.prototype.Multi = RedisClientMultiCommand.extend(config); + Client.prototype.Multi = RedisClientMultiCommand.extend(config) as RedisClientMultiCommandConstructor; RedisClient.#SingleEntryCache.set(config, Client); } @@ -350,8 +364,11 @@ export default class RedisClient< return ( options?: Omit, keyof Exclude> ) => { + const ClientCtor = Client as unknown as new ( + options?: Omit, keyof Exclude> + ) => RedisClientType; // returning a "proxy" to prevent the namespaces._self to leak between "proxies" - return Object.create(new Client(options)) as RedisClientType; + return Object.create(new ClientCtor(options)) as RedisClientType; }; } @@ -385,48 +402,100 @@ export default class RedisClient< tls: boolean } } { - // https://www.iana.org/assignments/uri-schemes/prov/redis - const { hostname, port, protocol, username, password, pathname } = new URL(url), - parsed: RedisClientOptions & { - socket: Exclude & { - tls: boolean - } - } = { - socket: { - // Use net.SocketAddress.parse() once supported. - host: hostname.replace(/^\[([0-9a-f:]+)\]$/, '$1'), - tls: false + const parsed: RedisClientOptions & { + socket: Exclude & { + tls: boolean + } + } = { + socket: { + tls: false + } + }; + + const setCredentials = (rawUsername: string, rawPassword: string) => { + const username = rawUsername ? decodeURIComponent(rawUsername) : undefined, + password = rawPassword ? decodeURIComponent(rawPassword) : undefined; + + if (username) { + parsed.username = username; + } + + if (password) { + parsed.password = password; + } + + if (username || password) { + parsed.credentialsProvider = { + type: 'async-credentials-provider', + credentials: async () => ({ + username, + password + }) + }; + } + }; + + if (url.startsWith('unix://')) { + const unixUrl = url.substring('unix://'.length), + pathStartIndex = unixUrl.indexOf('/'); + + if (pathStartIndex === -1) { + throw new TypeError('Invalid pathname'); + } + + const authority = unixUrl.substring(0, pathStartIndex); + if (authority) { + if (!authority.endsWith('@')) { + throw new TypeError('Invalid authority'); } + + const credentials = authority.substring(0, authority.length - 1), + separator = credentials.indexOf(':'); + setCredentials( + separator === -1 ? credentials : credentials.substring(0, separator), + separator === -1 ? '' : credentials.substring(separator + 1) + ); + } + + const { pathname, searchParams } = new URL(`unix://${unixUrl.substring(pathStartIndex)}`); + if (pathname.length <= 1) { + throw new TypeError('Invalid pathname'); + } + + parsed.socket = { + path: decodeURIComponent(pathname), + tls: false }; + const database = searchParams.get('db'); + if (database !== null) { + const parsedDatabase = Number(database); + if (isNaN(parsedDatabase)) { + throw new TypeError('Invalid database'); + } + + parsed.database = parsedDatabase; + } + + return parsed; + } + + // https://www.iana.org/assignments/uri-schemes/prov/redis + const { hostname, port, protocol, username, password, pathname } = new URL(url); if (protocol !== 'redis:' && protocol !== 'rediss:') { throw new TypeError('Invalid protocol'); } - parsed.socket.tls = protocol === 'rediss:'; + const host = hostname.replace(/^\[([0-9a-f:]+)\]$/, '$1'); + parsed.socket = protocol === 'rediss:' ? + { host, tls: true } : + { host, tls: false }; if (port) { (parsed.socket as TcpSocketConnectOpts).port = Number(port); } - if (username) { - parsed.username = decodeURIComponent(username); - } - - if (password) { - parsed.password = decodeURIComponent(password); - } - - if (username || password) { - parsed.credentialsProvider = { - type: 'async-credentials-provider', - credentials: async () => ( - { - username: username ? decodeURIComponent(username) : undefined, - password: password ? decodeURIComponent(password) : undefined - }) - }; - } + setCredentials(username, password); if (pathname.length > 1) { const database = Number(pathname.substring(1)); @@ -598,11 +667,11 @@ export default class RedisClient< const cscConfig = this.#options.clientSideCache; this.#clientSideCache = new BasicClientSideCache(cscConfig); } - this.#queue.addPushHandler((push: Array): boolean => { - if (push[0].toString() !== 'invalidate') return false; + this.#queue.addPushHandler((push: Array): boolean => { + if (String(push[0]) !== 'invalidate') return false; if (push[1] !== null) { - for (const key of push[1]) { + for (const key of push[1] as Array) { this.#clientSideCache?.invalidate(key) } } else { @@ -612,11 +681,11 @@ export default class RedisClient< return true }); } else if (options?.emitInvalidate) { - this.#queue.addPushHandler((push: Array): boolean => { - if (push[0].toString() !== 'invalidate') return false; + this.#queue.addPushHandler((push: Array): boolean => { + if (String(push[0]) !== 'invalidate') return false; if (push[1] !== null) { - for (const key of push[1]) { + for (const key of push[1] as Array) { this.emit('invalidate', key); } } else { @@ -1057,7 +1126,7 @@ export default class RedisClient< */ _ejectSocket(): RedisSocket { const socket = this._self.#socket; - // @ts-ignore + // @ts-expect-error temporarily clears the socket before reinserting one this._self.#socket = null; socket.removeAllListeners(); return socket; @@ -1524,7 +1593,7 @@ export default class RedisClient< MULTI() { type Multi = new (...args: ConstructorParameters) => RedisClientMultiCommandType; - return new ((this as any).Multi as Multi)( + return new ((this as unknown as { Multi: Multi }).Multi)( this._executeMulti.bind(this), this._executePipeline.bind(this), this._commandOptions?.typeMapping