Skip to content

Commit 30c2c75

Browse files
authored
fix: make ioredis optional (#4513)
1 parent 613da7a commit 30c2c75

4 files changed

Lines changed: 187 additions & 17 deletions

File tree

src/classes/ioredis-client.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Cluster, Redis, ChainableCommander } from 'ioredis';
1+
import type { Cluster, Redis, ChainableCommander } from 'ioredis';
22
import { IRedisClient, IRedisTransaction } from '../interfaces/redis-client';
33

44
/**
@@ -28,6 +28,26 @@ const proxyCache = new WeakMap<object, IRedisClient>();
2828
* traps, with `this === target` so EventEmitter / Commander internals work
2929
* normally.
3030
*/
31+
export function createIORedisClient<
32+
TClient extends {
33+
isCluster?: boolean;
34+
options?: any;
35+
pipeline(...args: any[]): {
36+
hset(...args: any[]): any;
37+
hscan(...args: any[]): any;
38+
sscan(...args: any[]): any;
39+
[key: string]: any;
40+
};
41+
multi(...args: any[]): {
42+
hset(...args: any[]): any;
43+
hscan(...args: any[]): any;
44+
sscan(...args: any[]): any;
45+
[key: string]: any;
46+
};
47+
duplicate(...args: any[]): any;
48+
[key: string]: any;
49+
},
50+
>(client: TClient): TClient & IRedisClient;
3151
export function createIORedisClient<TClient extends Redis | Cluster>(
3252
client: TClient,
3353
): TClient & IRedisClient {
@@ -180,8 +200,7 @@ export function createIORedisClient<TClient extends Redis | Cluster>(
180200
return (client as any).xadd(key, idOrModifier, fieldsOrArg, ...rest);
181201
}
182202
const options = rest[0] as
183-
| { MAXLEN?: number; approximate?: boolean }
184-
| undefined;
203+
{ MAXLEN?: number; approximate?: boolean } | undefined;
185204
const args: any[] = [key];
186205
if (options?.MAXLEN != null) {
187206
args.push('MAXLEN');

src/classes/redis-connection.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EventEmitter } from 'events';
2-
import { default as IORedis } from 'ioredis';
2+
import type { default as IORedis } from 'ioredis';
33
import { ConnectionOptions, RedisOptions, RedisClient } from '../interfaces';
44
import { IRedisClient } from '../interfaces/redis-client';
55
import {
@@ -14,6 +14,8 @@ import { version as packageVersion } from '../version';
1414
import * as scripts from '../scripts';
1515
import { DatabaseType } from '../types';
1616
import { createIORedisClient, isIRedisClient } from './ioredis-client';
17+
import { createNodeRedisClient } from './node-redis-client';
18+
import { createBunRedisClient } from './bun-redis-client';
1719
import {
1820
ConnectionClosedError,
1921
CONNECTION_CLOSED_ERROR_MSG,
@@ -67,6 +69,87 @@ export interface RawCommand {
6769
keys: number;
6870
}
6971

72+
type IORedisModule = { default: typeof IORedis };
73+
74+
/**
75+
* Lazily loads the optional `ioredis` driver. Users on another Redis driver
76+
* (node-redis, Bun built-in, …) or on the PostgreSQL backend never hit this
77+
* path, so they never need `ioredis` installed.
78+
*
79+
* Only reached when no {@link RedisConnection.clientFactory} is set and the
80+
* caller did not pass an already-constructed client instance. In native ESM
81+
* environments where `require` is unavailable, callers should provide a client
82+
* instance or a `clientFactory` instead.
83+
*/
84+
function loadIORedis(): typeof IORedis {
85+
try {
86+
if (typeof require === 'function') {
87+
const mod = require('ioredis') as IORedisModule | typeof IORedis;
88+
// ioredis exports the constructor both as the module itself (CJS) and
89+
// under `default` (ESM interop); normalise to the constructor.
90+
return (mod as IORedisModule).default ?? (mod as typeof IORedis);
91+
}
92+
} catch {
93+
// Fall through to the friendly error below.
94+
}
95+
throw new Error(
96+
"BullMQ could not load the optional 'ioredis' package. " +
97+
'Install it with `npm install ioredis`, or provide a different Redis ' +
98+
'client instance (e.g. node-redis) via the connection option. In a ' +
99+
'native ESM environment, pass an already-constructed client instance ' +
100+
'instead of connection options.',
101+
);
102+
}
103+
104+
/**
105+
* Wraps a raw client instance passed through the `connection` option in the
106+
* matching {@link IRedisClient} adapter, auto-detecting the underlying driver.
107+
*
108+
* This lets consumers pass a native node-redis or Bun client directly (without
109+
* manually calling `createNodeRedisClient` / `createBunRedisClient` or setting a
110+
* global {@link RedisConnection.clientFactory}), so those users never need
111+
* `ioredis` installed. ioredis instances keep their existing code path, so the
112+
* behaviour is fully backwards compatible.
113+
*
114+
* Detection is purely structural (no driver package is imported), keying off
115+
* markers that are unique to each client:
116+
* - ioredis exposes `defineCommand` (used to register Lua scripts);
117+
* node-redis and Bun do not.
118+
* - node-redis (`@redis/client`) exposes `sendCommand` plus `isOpen`/`isReady`.
119+
* - Bun's built-in `RedisClient` exposes `send` plus a `connected` flag.
120+
*/
121+
function wrapRedisInstance(instance: any): IRedisClient {
122+
// Already an adapted IRedisClient (ioredis proxy, node-redis, Bun, or a
123+
// custom implementation) — use as-is.
124+
if (isIRedisClient(instance)) {
125+
return instance;
126+
}
127+
128+
const hasDefineCommand = typeof instance.defineCommand === 'function';
129+
130+
// node-redis (@redis/client): `sendCommand` + `isOpen`/`isReady`, and no
131+
// ioredis-style `defineCommand`.
132+
if (
133+
!hasDefineCommand &&
134+
typeof instance.sendCommand === 'function' &&
135+
('isOpen' in instance || 'isReady' in instance)
136+
) {
137+
return createNodeRedisClient(instance);
138+
}
139+
140+
// Bun's built-in RedisClient: `send` + `connected`, and no `defineCommand`.
141+
if (
142+
!hasDefineCommand &&
143+
typeof instance.send === 'function' &&
144+
'connected' in instance
145+
) {
146+
return createBunRedisClient(instance);
147+
}
148+
149+
// Default: treat as an ioredis instance (backwards compatible).
150+
return createIORedisClient(instance);
151+
}
152+
70153
export class RedisConnection extends EventEmitter {
71154
static minimumVersion = '5.0.0';
72155
static recommendedMinimumVersion = '6.2.0';
@@ -156,10 +239,10 @@ export class RedisConnection extends EventEmitter {
156239
this.opts.maxRetriesPerRequest = null;
157240
}
158241
} else {
159-
// Wrap raw ioredis instances in the IRedisClient adapter if not already wrapped
160-
this._client = isIRedisClient(opts)
161-
? opts
162-
: createIORedisClient(opts as any);
242+
// Wrap raw client instances in the matching IRedisClient adapter,
243+
// auto-detecting the driver (ioredis / node-redis / Bun) so callers can
244+
// pass a native client directly without setting a clientFactory.
245+
this._client = wrapRedisInstance(opts);
163246

164247
// Test if the redis instance is using keyPrefix
165248
// and if so, throw an error.
@@ -321,7 +404,10 @@ export class RedisConnection extends EventEmitter {
321404
this._client = RedisConnection.clientFactory(this.opts);
322405
} else {
323406
const { url, ...rest } = this.opts;
324-
const ioredisClient = url ? new IORedis(url, rest) : new IORedis(rest);
407+
const IORedisCtor = loadIORedis();
408+
const ioredisClient = url
409+
? new IORedisCtor(url, rest)
410+
: new IORedisCtor(rest);
325411
this._client = createIORedisClient(ioredisClient);
326412
}
327413
}

src/interfaces/redis-options.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,49 @@
1-
import type * as IORedis from 'ioredis';
21
import { IRedisClient } from './redis-client';
32

43
export interface BaseOptions {
54
skipVersionCheck?: boolean;
65
url?: string;
76
}
87

9-
export type RedisOptions = IORedis.RedisOptions & BaseOptions;
8+
export interface RedisOptions extends BaseOptions {
9+
host?: string;
10+
port?: number;
11+
family?: number;
12+
db?: number;
13+
username?: string;
14+
password?: string;
15+
connectionName?: string;
16+
keyPrefix?: string;
17+
enableOfflineQueue?: boolean;
18+
maxRetriesPerRequest?: number | null;
19+
retryStrategy?: (times: number) => number | void | null;
20+
lazyConnect?: boolean;
21+
tls?: any;
22+
[key: string]: any;
23+
}
24+
25+
export interface ClusterOptions extends BaseOptions {
26+
maxRetriesPerRequest?: number | null;
27+
enableOfflineQueue?: boolean;
28+
retryStrategy?: (times: number) => number | void | null;
29+
lazyConnect?: boolean;
30+
redisOptions?: RedisOptions;
31+
scaleReads?: string;
32+
[key: string]: any;
33+
}
1034

11-
export type ClusterOptions = IORedis.ClusterOptions & BaseOptions;
35+
export interface RedisConnectionClient {
36+
connect(...args: any[]): any;
37+
duplicate(...args: any[]): any;
38+
disconnect?(...args: any[]): any;
39+
close?(...args: any[]): any;
40+
on?(...args: any[]): any;
41+
defineCommand?(...args: any[]): any;
42+
sendCommand?(...args: any[]): any;
43+
send?(...args: any[]): any;
44+
isCluster?: boolean;
45+
[key: string]: any;
46+
}
1247

1348
export type ConnectionOptions =
14-
| RedisOptions
15-
| ClusterOptions
16-
| IORedis.Redis
17-
| IRedisClient
18-
| IORedis.Cluster;
49+
RedisOptions | ClusterOptions | IRedisClient | RedisConnectionClient;

tests/node-redis.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,4 +362,38 @@ describe('node-redis adapter', () => {
362362
await cleanQueue(childQueueName);
363363
});
364364
});
365+
366+
describe('auto-detecting a raw node-redis client instance', () => {
367+
it('should auto-wrap a raw createClient() instance passed as connection', async () => {
368+
// No clientFactory involved and no manual createNodeRedisClient(): the
369+
// raw node-redis client is passed straight through and BullMQ must detect
370+
// it and wrap it with the node-redis adapter (never requiring ioredis).
371+
const savedFactory = RedisConnection.clientFactory;
372+
RedisConnection.clientFactory = undefined;
373+
374+
const raw = createClient({
375+
url: `redis://${redisHost}:${redisPort}`,
376+
}) as RedisClientType;
377+
await raw.connect();
378+
379+
const queueName = `test-nr-autowrap-${randomUUID()}`;
380+
const queue = new Queue(queueName, {
381+
connection: raw as unknown as IRedisClient,
382+
prefix,
383+
});
384+
385+
try {
386+
const job = await queue.add('auto', { hello: 'world' });
387+
expect(job.id).toBeDefined();
388+
389+
const fetched = await Job.fromId(queue, job.id);
390+
expect(fetched?.data).toEqual({ hello: 'world' });
391+
} finally {
392+
await queue.close();
393+
await cleanQueue(queueName);
394+
await raw.quit();
395+
RedisConnection.clientFactory = savedFactory;
396+
}
397+
});
398+
});
365399
});

0 commit comments

Comments
 (0)