diff --git a/docs/client-configuration.md b/docs/client-configuration.md index ace10475be..fd826e2e1b 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -2,7 +2,7 @@ | 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) | +| 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), or `unix://[[username][:password]@]/path/to/socket[?db=N]` for a UNIX domain socket | | socket | | Socket connection properties. Unlisted [`net.connect`](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) properties (and [`tls.connect`](https://nodejs.org/api/tls.html#tlsconnectoptions-callback)) are also supported | | socket.port | `6379` | Redis server port | | socket.host | `'localhost'` | Redis server hostname | diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index d520f970b0..b5f89941a1 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -287,6 +287,100 @@ describe('Client', () => { } }); }); + + describe('unix socket URLs', () => { + it('unix:///tmp/redis.sock', () => { + assert.deepEqual( + RedisClient.parseURL('unix:///tmp/redis.sock'), + { + socket: { + path: '/tmp/redis.sock', + tls: false + } + } + ); + }); + + 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=2', async () => { + const result = RedisClient.parseURL('unix://user:secret@/tmp/redis.sock?db=2'); + const expected: RedisClientOptions = { + socket: { + path: '/tmp/redis.sock', + tls: false + }, + username: 'user', + password: 'secret', + database: 2, + credentialsProvider: { + type: 'async-credentials-provider', + credentials: async () => ({ + username: 'user', + password: 'secret' + }) + } + }; + + const { credentialsProvider: _r, ...resultRest } = result; + const { credentialsProvider: _e, ...expectedRest } = expected; + assert.deepEqual(resultRest, expectedRest); + + if (result.credentialsProvider?.type === 'async-credentials-provider' + && expected.credentialsProvider?.type === 'async-credentials-provider') { + assert.deepEqual( + await result.credentialsProvider.credentials(), + await expected.credentialsProvider.credentials() + ); + } else { + assert.fail('Credentials provider type mismatch'); + } + }); + + it('percent-encoded path is decoded', () => { + assert.deepEqual( + RedisClient.parseURL('unix:///var/run/redis%20test.sock'), + { + socket: { + path: '/var/run/redis test.sock', + tls: false + } + } + ); + }); + + it('missing path is rejected', () => { + assert.throws( + () => RedisClient.parseURL('unix://'), + TypeError + ); + }); + + it('empty path is rejected', () => { + assert.throws( + () => RedisClient.parseURL('unix:///'), + TypeError + ); + }); + + it('invalid db query parameter is rejected', () => { + assert.throws( + () => RedisClient.parseURL('unix:///tmp/redis.sock?db=NaN'), + TypeError + ); + }); + }); }); describe('parseOptions', () => { diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index c20c75830e..cb20201f03 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -255,7 +255,7 @@ export type RedisClientType< RedisClientExtensions ); -type ProxyClient = RedisClient; +type ProxyClient = RedisClient; type NamespaceProxyClient = { _self: ProxyClient }; @@ -320,6 +320,7 @@ export default class RedisClient< } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any static #SingleEntryCache = new SingleEntryCache() static factory< @@ -385,6 +386,12 @@ export default class RedisClient< tls: boolean } } { + // unix:// URIs use a non-special scheme; WHATWG URL refuses to parse an + // authority (e.g. `user:pass@`) without a host, so handle it separately. + if (url.startsWith('unix:')) { + return RedisClient.#parseUnixURL(url); + } + // https://www.iana.org/assignments/uri-schemes/prov/redis const { hostname, port, protocol, username, password, pathname } = new URL(url), parsed: RedisClientOptions & { @@ -440,6 +447,62 @@ export default class RedisClient< return parsed; } + static #parseUnixURL(url: string): RedisClientOptions & { + socket: Exclude & { + tls: boolean + } + } { + // unix://[user[:password]@]/path/to/sock[?db=N] + const match = /^unix:\/\/(?:([^:@/]*)(?::([^@/]*))?@)?(\/[^?#]*)(?:\?([^#]*))?(?:#.*)?$/.exec(url); + if (!match || match[3] === '/') { + throw new TypeError('Invalid unix URL'); + } + + const [, username, password, rawPath, rawQuery] = match, + parsed: RedisClientOptions & { + socket: Exclude & { + tls: boolean + } + } = { + socket: { + path: decodeURIComponent(rawPath), + tls: false + } + }; + + 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 + }) + }; + } + + if (rawQuery) { + const db = new URLSearchParams(rawQuery).get('db'); + if (db !== null) { + const database = Number(db); + if (isNaN(database)) { + throw new TypeError('Invalid db query parameter'); + } + parsed.database = database; + } + } + + return parsed; + } + readonly #options: RedisClientOptions; #socket: RedisSocket; readonly #queue: RedisCommandsQueue; @@ -598,6 +661,7 @@ export default class RedisClient< const cscConfig = this.#options.clientSideCache; this.#clientSideCache = new BasicClientSideCache(cscConfig); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any this.#queue.addPushHandler((push: Array): boolean => { if (push[0].toString() !== 'invalidate') return false; @@ -612,6 +676,7 @@ export default class RedisClient< return true }); } else if (options?.emitInvalidate) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any this.#queue.addPushHandler((push: Array): boolean => { if (push[0].toString() !== 'invalidate') return false; @@ -1057,7 +1122,7 @@ export default class RedisClient< */ _ejectSocket(): RedisSocket { const socket = this._self.#socket; - // @ts-ignore + // @ts-expect-error null assignment is intentional during eject this._self.#socket = null; socket.removeAllListeners(); return socket; @@ -1524,7 +1589,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