@oneunit/redis 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +422 -0
- package/CHANGELOG.md +186 -0
- package/CONTRIBUTING.md +353 -0
- package/LICENSE +21 -0
- package/README.md +760 -2
- package/dist/client/check.d.ts +19 -0
- package/dist/client/check.d.ts.map +1 -0
- package/dist/client/check.js +44 -0
- package/dist/client/check.js.map +1 -0
- package/dist/client/client.d.ts +10 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +25 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/events.d.ts +5 -0
- package/dist/client/events.d.ts.map +1 -0
- package/dist/client/events.js +66 -0
- package/dist/client/events.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/shutdown.d.ts +4 -0
- package/dist/client/shutdown.d.ts.map +1 -0
- package/dist/client/shutdown.js +78 -0
- package/dist/client/shutdown.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +120 -0
- package/dist/logger.js.map +1 -0
- package/dist/pipeline/builder.d.ts +111 -0
- package/dist/pipeline/builder.d.ts.map +1 -0
- package/dist/pipeline/builder.js +197 -0
- package/dist/pipeline/builder.js.map +1 -0
- package/dist/pipeline/index.d.ts +3 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/queue/events.d.ts +13 -0
- package/dist/queue/events.d.ts.map +1 -0
- package/dist/queue/events.js +109 -0
- package/dist/queue/events.js.map +1 -0
- package/dist/queue/index.d.ts +7 -0
- package/dist/queue/index.d.ts.map +1 -0
- package/dist/queue/index.js +4 -0
- package/dist/queue/index.js.map +1 -0
- package/dist/queue/queue.d.ts +13 -0
- package/dist/queue/queue.d.ts.map +1 -0
- package/dist/queue/queue.js +37 -0
- package/dist/queue/queue.js.map +1 -0
- package/dist/queue/worker.d.ts +15 -0
- package/dist/queue/worker.d.ts.map +1 -0
- package/dist/queue/worker.js +20 -0
- package/dist/queue/worker.js.map +1 -0
- package/examples/README.md +86 -0
- package/examples/_setup.js +143 -0
- package/examples/cache.js +111 -0
- package/examples/pipeline.js +161 -0
- package/examples/pubsub.js +101 -0
- package/examples/queue-worker.js +189 -0
- package/examples/session.js +145 -0
- package/examples/standalone.js +58 -0
- package/package.json +100 -4
- package/src/client/check.ts +69 -0
- package/src/client/client.ts +45 -0
- package/src/client/events.ts +101 -0
- package/src/client/index.ts +5 -0
- package/src/client/shutdown.ts +97 -0
- package/src/index.ts +4 -0
- package/src/logger.ts +159 -0
- package/src/pipeline/builder.ts +307 -0
- package/src/pipeline/index.ts +7 -0
- package/src/queue/events.ts +158 -0
- package/src/queue/index.ts +6 -0
- package/src/queue/queue.ts +60 -0
- package/src/queue/worker.ts +44 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session store on Redis.
|
|
3
|
+
*
|
|
4
|
+
* npm run example:session
|
|
5
|
+
*
|
|
6
|
+
* Environment:
|
|
7
|
+
* REDIS_URL connection URL (default redis://localhost:6379)
|
|
8
|
+
* REDIS_SILENT set to "true" to suppress connection-event logging
|
|
9
|
+
*
|
|
10
|
+
* Sessions belong in Redis rather than in process memory for two reasons: they
|
|
11
|
+
* survive a restart, and they are visible to every instance behind a load
|
|
12
|
+
* balancer. The TTL is what bounds their lifetime, so it is set on write and
|
|
13
|
+
* refreshed on activity.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { createClient, health, shutdown } from "@oneunit/redis";
|
|
18
|
+
import { REDIS_URL, exampleLogger, onFailure, release, run } from "./_setup.js";
|
|
19
|
+
|
|
20
|
+
const SESSION_TTL_SECONDS = 3600;
|
|
21
|
+
const KEY_PREFIX = "example:session:";
|
|
22
|
+
|
|
23
|
+
await run("session", async () => {
|
|
24
|
+
const client = createClient({ url: REDIS_URL }, exampleLogger);
|
|
25
|
+
onFailure(() => shutdown(client, exampleLogger));
|
|
26
|
+
|
|
27
|
+
const healthResult = await health(client);
|
|
28
|
+
if (healthResult.status === "down") {
|
|
29
|
+
// Always shut down, including on this early exit: ioredis retries in the
|
|
30
|
+
// background, so a client left open keeps the event loop alive and the
|
|
31
|
+
// script never exits.
|
|
32
|
+
console.log("Redis is not reachable, stopping here.");
|
|
33
|
+
console.log("Health:", healthResult);
|
|
34
|
+
await release();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = (sessionId) => `${KEY_PREFIX}${sessionId}`;
|
|
39
|
+
|
|
40
|
+
const sessions = {
|
|
41
|
+
async create(userId, data = {}) {
|
|
42
|
+
const session = {
|
|
43
|
+
id: randomUUID(),
|
|
44
|
+
userId,
|
|
45
|
+
createdAt: Date.now(),
|
|
46
|
+
...data,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
await client.setex(
|
|
50
|
+
key(session.id),
|
|
51
|
+
SESSION_TTL_SECONDS,
|
|
52
|
+
JSON.stringify(session),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return session;
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async get(sessionId) {
|
|
59
|
+
// Returns null rather than throwing for a missing session, so callers
|
|
60
|
+
// treat "no session" and "expired session" the same way.
|
|
61
|
+
const raw = await client.get(key(sessionId));
|
|
62
|
+
return raw === null ? null : JSON.parse(raw);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async update(sessionId, patch) {
|
|
66
|
+
const existing = await sessions.get(sessionId);
|
|
67
|
+
if (!existing) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const updated = { ...existing, ...patch, updatedAt: Date.now() };
|
|
72
|
+
|
|
73
|
+
// Rewriting with SETEX resets the TTL to the full window. Refresh on
|
|
74
|
+
// a sliding window only; an absolute-expiry session would use PERSIST
|
|
75
|
+
// here instead so the original deadline stands.
|
|
76
|
+
await client.setex(
|
|
77
|
+
key(sessionId),
|
|
78
|
+
SESSION_TTL_SECONDS,
|
|
79
|
+
JSON.stringify(updated),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return updated;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async extend(sessionId, ttlSeconds = SESSION_TTL_SECONDS) {
|
|
86
|
+
// EXPIRE on a missing key returns 0, not 1, which is the cheapest way
|
|
87
|
+
// to tell whether the session was still alive.
|
|
88
|
+
return client.expire(key(sessionId), ttlSeconds);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async touch(sessionId) {
|
|
92
|
+
// Refresh the TTL without reading or rewriting the payload.
|
|
93
|
+
return sessions.extend(sessionId);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async destroy(sessionId) {
|
|
97
|
+
return client.del(key(sessionId));
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
console.log("Create:");
|
|
102
|
+
const session = await sessions.create("user-123", {
|
|
103
|
+
role: "admin",
|
|
104
|
+
permissions: ["read", "write"],
|
|
105
|
+
});
|
|
106
|
+
console.log(" ", session);
|
|
107
|
+
console.log(" TTL:", await client.ttl(key(session.id)), "seconds");
|
|
108
|
+
|
|
109
|
+
console.log("\nGet:");
|
|
110
|
+
console.log(" ", await sessions.get(session.id));
|
|
111
|
+
|
|
112
|
+
console.log("\nGet a session that does not exist:");
|
|
113
|
+
console.log(" ", await sessions.get("not-a-real-session"));
|
|
114
|
+
|
|
115
|
+
console.log("\nUpdate:");
|
|
116
|
+
const updated = await sessions.update(session.id, {
|
|
117
|
+
lastActivity: Date.now(),
|
|
118
|
+
});
|
|
119
|
+
console.log(
|
|
120
|
+
" role:",
|
|
121
|
+
updated.role,
|
|
122
|
+
"| updatedAt set:",
|
|
123
|
+
updated.updatedAt > updated.createdAt,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
console.log("\nExtend the TTL to 2 hours:");
|
|
127
|
+
const extended = await sessions.extend(session.id, 7200);
|
|
128
|
+
console.log(
|
|
129
|
+
" EXPIRE returned",
|
|
130
|
+
extended,
|
|
131
|
+
"| TTL now:",
|
|
132
|
+
await client.ttl(key(session.id)),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
console.log("\nTouch (refresh TTL without rewriting the payload):");
|
|
136
|
+
await sessions.touch(session.id);
|
|
137
|
+
console.log(" TTL:", await client.ttl(key(session.id)));
|
|
138
|
+
|
|
139
|
+
console.log("\nDestroy:");
|
|
140
|
+
console.log(" DEL removed", await sessions.destroy(session.id), "key(s)");
|
|
141
|
+
console.log(" get after destroy:", await sessions.get(session.id));
|
|
142
|
+
|
|
143
|
+
await release();
|
|
144
|
+
console.log("\nDisconnected cleanly.");
|
|
145
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone client: connect, check health, read and write, disconnect.
|
|
3
|
+
*
|
|
4
|
+
* npm run example:standalone
|
|
5
|
+
*
|
|
6
|
+
* Environment:
|
|
7
|
+
* REDIS_URL connection URL (default redis://localhost:6379)
|
|
8
|
+
* REDIS_SILENT set to "true" to suppress connection-event logging
|
|
9
|
+
*
|
|
10
|
+
* `createClient` returns a plain ioredis instance, so every ioredis command is
|
|
11
|
+
* available on it. The defaults are chosen so one client works for both plain
|
|
12
|
+
* commands and BullMQ: `url` falls back to REDIS_URL, `lazyConnect` avoids
|
|
13
|
+
* opening a socket at construction, and `maxRetriesPerRequest: null` is what
|
|
14
|
+
* BullMQ requires.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createClient, health, shutdown } from "@oneunit/redis";
|
|
18
|
+
import { REDIS_URL, exampleLogger, onFailure, release, run } from "./_setup.js";
|
|
19
|
+
|
|
20
|
+
await run("standalone", async () => {
|
|
21
|
+
const client = createClient({ url: REDIS_URL }, exampleLogger);
|
|
22
|
+
onFailure(() => shutdown(client, exampleLogger));
|
|
23
|
+
|
|
24
|
+
// `lazyConnect` is on, so nothing is connected until the first command.
|
|
25
|
+
// `health` issues PING and reports how long it took.
|
|
26
|
+
const healthResult = await health(client);
|
|
27
|
+
|
|
28
|
+
if (healthResult.status === "down") {
|
|
29
|
+
// Always shut the client down, including on this early exit. ioredis
|
|
30
|
+
// retries in the background, so a client left open keeps the event loop
|
|
31
|
+
// alive and the script never exits.
|
|
32
|
+
console.log("Redis is not reachable, stopping here.");
|
|
33
|
+
console.log("Set REDIS_URL if your server is not on localhost:6379.");
|
|
34
|
+
console.log("Health:", healthResult);
|
|
35
|
+
await release();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log("Health:", healthResult);
|
|
40
|
+
|
|
41
|
+
await client.set("greeting", "hello from @oneunit/redis");
|
|
42
|
+
console.log("GET greeting:", await client.get("greeting"));
|
|
43
|
+
|
|
44
|
+
// INCR is atomic in Redis, so concurrent callers cannot interleave.
|
|
45
|
+
await client.set("visits", 0);
|
|
46
|
+
const visits = await client.incrby("visits", 5);
|
|
47
|
+
console.log("INCRBY visits 5 ->", visits);
|
|
48
|
+
|
|
49
|
+
// EXPIRE sets a TTL in seconds. A key with no TTL lives forever, which is
|
|
50
|
+
// the usual cause of a Redis instance quietly filling up.
|
|
51
|
+
await client.expire("greeting", 60);
|
|
52
|
+
console.log("TTL greeting:", await client.ttl("greeting"), "seconds");
|
|
53
|
+
|
|
54
|
+
await client.del("greeting", "visits");
|
|
55
|
+
|
|
56
|
+
await release();
|
|
57
|
+
console.log("\nDisconnected cleanly.");
|
|
58
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,102 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneunit/redis",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Redis client and BullMQ job queues for Node.js. Works standalone or with any logger that has error, warn, info, and debug.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"types": "./dist/client/index.d.ts",
|
|
17
|
+
"import": "./dist/client/index.js",
|
|
18
|
+
"default": "./dist/client/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./queue": {
|
|
21
|
+
"types": "./dist/queue/index.d.ts",
|
|
22
|
+
"import": "./dist/queue/index.js",
|
|
23
|
+
"default": "./dist/queue/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./pipeline": {
|
|
26
|
+
"types": "./dist/pipeline/index.d.ts",
|
|
27
|
+
"import": "./dist/pipeline/index.js",
|
|
28
|
+
"default": "./dist/pipeline/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"src",
|
|
35
|
+
"examples",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"README.md",
|
|
38
|
+
"ARCHITECTURE.md",
|
|
39
|
+
"CONTRIBUTING.md",
|
|
40
|
+
"CHANGELOG.md"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"clean": "node -e \"const fs=require('node:fs');for(const p of ['dist','tsconfig.tsbuildinfo'])fs.rmSync(p,{recursive:true,force:true})\"",
|
|
44
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
45
|
+
"dev": "tsc -w -p tsconfig.json",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
|
+
"test": "tsx --test test/*.test.ts",
|
|
48
|
+
"test:watch": "tsx --test --watch test/*.test.ts",
|
|
49
|
+
"lint": "eslint src test",
|
|
50
|
+
"example:standalone": "npm run build && node examples/standalone.js",
|
|
51
|
+
"example:cache": "npm run build && node examples/cache.js",
|
|
52
|
+
"example:session": "npm run build && node examples/session.js",
|
|
53
|
+
"example:pipeline": "npm run build && node examples/pipeline.js",
|
|
54
|
+
"example:pubsub": "npm run build && node examples/pubsub.js",
|
|
55
|
+
"example:queue-worker": "npm run build && node examples/queue-worker.js",
|
|
56
|
+
"prepack": "npm run build",
|
|
57
|
+
"prepublishOnly": "npm run build && npm test",
|
|
58
|
+
"pack:check": "npm pack --dry-run",
|
|
59
|
+
"verify": "npm run build && npm run typecheck && npm run lint && npm test && npm run pack:check"
|
|
60
|
+
},
|
|
61
|
+
"keywords": [
|
|
62
|
+
"redis",
|
|
63
|
+
"ioredis",
|
|
64
|
+
"bullmq",
|
|
65
|
+
"queue",
|
|
66
|
+
"worker",
|
|
67
|
+
"cache",
|
|
68
|
+
"session",
|
|
69
|
+
"nodejs",
|
|
70
|
+
"microservice",
|
|
71
|
+
"oneunit"
|
|
72
|
+
],
|
|
73
|
+
"author": "mayank",
|
|
74
|
+
"license": "MIT",
|
|
75
|
+
"sideEffects": false,
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": ">=20"
|
|
78
|
+
},
|
|
79
|
+
"publishConfig": {
|
|
80
|
+
"access": "public",
|
|
81
|
+
"registry": "https://registry.npmjs.org/"
|
|
82
|
+
},
|
|
83
|
+
"repository": {
|
|
84
|
+
"type": "git",
|
|
85
|
+
"url": "git+https://github.com/mayank040902/oneunit.git",
|
|
86
|
+
"directory": "packages/redis"
|
|
87
|
+
},
|
|
88
|
+
"bugs": {
|
|
89
|
+
"url": "https://github.com/mayank040902/oneunit/issues"
|
|
90
|
+
},
|
|
91
|
+
"homepage": "https://github.com/mayank040902/oneunit/tree/master/packages/redis#readme",
|
|
92
|
+
"dependencies": {
|
|
93
|
+
"bullmq": "^5.80.9",
|
|
94
|
+
"ioredis": "^5.11.1"
|
|
95
|
+
},
|
|
96
|
+
"devDependencies": {
|
|
97
|
+
"@types/node": "^22.13.10",
|
|
98
|
+
"eslint": "^9.22.0",
|
|
99
|
+
"tsx": "^4.20.5",
|
|
100
|
+
"typescript": "^5.8.2"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { type Redis as RedisClient } from "ioredis";
|
|
3
|
+
|
|
4
|
+
export interface HealthResult {
|
|
5
|
+
status: "up" | "down";
|
|
6
|
+
latency: {
|
|
7
|
+
value: number;
|
|
8
|
+
unit: "ms";
|
|
9
|
+
};
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HealthOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Milliseconds to wait for PING before reporting `down`. ioredis queues
|
|
16
|
+
* commands while reconnecting, so without a bound a PING against an
|
|
17
|
+
* unreachable server never settles and health checks hang forever.
|
|
18
|
+
*/
|
|
19
|
+
timeout?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 1000;
|
|
23
|
+
|
|
24
|
+
export async function health(
|
|
25
|
+
client: RedisClient,
|
|
26
|
+
options: HealthOptions = {},
|
|
27
|
+
): Promise<HealthResult> {
|
|
28
|
+
const requested = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
29
|
+
// setTimeout coerces negatives to 1 and NaN to 1, and Node prints a
|
|
30
|
+
// TimeoutNegativeWarning/TimeoutNaNWarning for each. Treat any invalid
|
|
31
|
+
// budget as "no bound configured" rather than leaking warnings per call.
|
|
32
|
+
const timeout =
|
|
33
|
+
Number.isFinite(requested) && requested > 0
|
|
34
|
+
? requested
|
|
35
|
+
: DEFAULT_TIMEOUT_MS;
|
|
36
|
+
const start = performance.now();
|
|
37
|
+
|
|
38
|
+
const elapsed = (): number => Math.round(performance.now() - start);
|
|
39
|
+
|
|
40
|
+
let timer: NodeJS.Timeout | undefined;
|
|
41
|
+
const expiry = new Promise<never>((_resolve, reject) => {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
reject(new Error(`Health check timed out after ${timeout}ms`));
|
|
44
|
+
}, timeout);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await Promise.race([client.ping(), expiry]);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
status: "up",
|
|
52
|
+
latency: {
|
|
53
|
+
value: elapsed(),
|
|
54
|
+
unit: "ms",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
status: "down",
|
|
60
|
+
latency: {
|
|
61
|
+
value: elapsed(),
|
|
62
|
+
unit: "ms",
|
|
63
|
+
},
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
};
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
import { attachEvents } from "./events.js";
|
|
3
|
+
import { normalizeLogger, type Logger } from "../logger.js";
|
|
4
|
+
import type { RedisOptions } from "ioredis";
|
|
5
|
+
|
|
6
|
+
export interface RedisClientOptions extends RedisOptions {
|
|
7
|
+
url?: string;
|
|
8
|
+
lazyConnect?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createClient(
|
|
12
|
+
options: RedisClientOptions | string = {},
|
|
13
|
+
logger?: Logger,
|
|
14
|
+
): Redis {
|
|
15
|
+
// ioredis's own constructor accepts `new Redis("redis://host:port")`, so
|
|
16
|
+
// that is the form people reach for first. Destructuring the string as an
|
|
17
|
+
// options object produces no `url`, which leaves the client pointing at
|
|
18
|
+
// localhost:6379: a silent connection to the wrong server, with no error
|
|
19
|
+
// anywhere to notice it by.
|
|
20
|
+
const resolved: RedisClientOptions =
|
|
21
|
+
typeof options === "string" ? { url: options } : options;
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
url = process.env.REDIS_URL,
|
|
25
|
+
lazyConnect = true,
|
|
26
|
+
// BullMQ throws unless this is null on the connections it drives, and
|
|
27
|
+
// `createQueue`/`createWorker` take this same client instance.
|
|
28
|
+
maxRetriesPerRequest = null,
|
|
29
|
+
...restOptions
|
|
30
|
+
} = resolved;
|
|
31
|
+
|
|
32
|
+
// ioredis only reads `url` from the first positional argument; an options
|
|
33
|
+
// object carrying `url` is silently ignored and connects to localhost.
|
|
34
|
+
const client = url
|
|
35
|
+
? new Redis(url, { ...restOptions, lazyConnect, maxRetriesPerRequest })
|
|
36
|
+
: new Redis({ ...restOptions, lazyConnect, maxRetriesPerRequest });
|
|
37
|
+
|
|
38
|
+
// A caller-supplied logger may implement only some of the levels. Completing
|
|
39
|
+
// it here keeps `attachEvents` from throwing mid-connection-event.
|
|
40
|
+
attachEvents(client, normalizeLogger(logger));
|
|
41
|
+
|
|
42
|
+
return client;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type { Logger } from "../logger.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { normalizeLogger, type Logger } from "../logger.js";
|
|
2
|
+
import { type Redis as RedisClient } from "ioredis";
|
|
3
|
+
|
|
4
|
+
/** Command names whose arguments must never be logged. */
|
|
5
|
+
const SECRET_COMMANDS = new Set(["auth", "hello"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Strip credentials before an error reaches a logger.
|
|
9
|
+
*
|
|
10
|
+
* ioredis attaches the failing command to its errors, and for `AUTH` that
|
|
11
|
+
* command's args are the username and the password in plaintext:
|
|
12
|
+
*
|
|
13
|
+
* { command: { name: "auth", args: ["default", "hunter2-real-secret"] } }
|
|
14
|
+
*
|
|
15
|
+
* Logging the error as-is writes the Redis password to whatever the app logs
|
|
16
|
+
* to, on every failed authentication. The message itself is safe; only the
|
|
17
|
+
* attached command is not, so only that part is replaced.
|
|
18
|
+
*/
|
|
19
|
+
/** An ioredis error, which carries the failing command alongside the message. */
|
|
20
|
+
type CommandError = Error & {
|
|
21
|
+
command?: { name?: unknown; args?: unknown } | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function redactError(error: unknown): unknown {
|
|
25
|
+
if (!(error instanceof Error)) {
|
|
26
|
+
return error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const command = (error as CommandError).command;
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
typeof command === "object" &&
|
|
33
|
+
command !== null &&
|
|
34
|
+
SECRET_COMMANDS.has(String(command.name).toLowerCase())
|
|
35
|
+
) {
|
|
36
|
+
// Copy rather than mutate: ioredis may still be using this error, and the
|
|
37
|
+
// caller has no reason to lose the original for their own handling.
|
|
38
|
+
//
|
|
39
|
+
// `message` is deliberately kept. Redis error text is what tells an
|
|
40
|
+
// operator *why* authentication failed, and it never echoes the password
|
|
41
|
+
// back. Only the attached command's args carry it.
|
|
42
|
+
const safe = new Error(error.message) as CommandError;
|
|
43
|
+
safe.name = error.name;
|
|
44
|
+
safe.stack = error.stack;
|
|
45
|
+
safe.command = { ...command, args: "[redacted]" };
|
|
46
|
+
|
|
47
|
+
return safe;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Marks a client this function has already wired up.
|
|
55
|
+
*
|
|
56
|
+
* `attachEvents` is exported and `createClient` calls it. A caller reaching for
|
|
57
|
+
* the exported helper on a client from `createClient` would otherwise get a
|
|
58
|
+
* second full set of listeners, logging every connection event twice. BullMQ
|
|
59
|
+
* adds listeners to the same client too, so crossing the default limit of 10
|
|
60
|
+
* and tripping `MaxListenersExceededWarning` should not be something a
|
|
61
|
+
* consumer causes by accident.
|
|
62
|
+
*
|
|
63
|
+
* A global symbol is used so two copies of this package in one dependency tree
|
|
64
|
+
* still recognise a client the other already wired.
|
|
65
|
+
*/
|
|
66
|
+
const WIRED = Symbol.for("oneunit.redis.eventsAttached");
|
|
67
|
+
|
|
68
|
+
export function attachEvents(client: RedisClient, loggerInput?: Logger): void {
|
|
69
|
+
if ((client as unknown as Record<symbol, boolean>)[WIRED]) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Object.defineProperty(client, WIRED, {
|
|
74
|
+
value: true,
|
|
75
|
+
enumerable: false,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const logger = normalizeLogger(loggerInput);
|
|
79
|
+
|
|
80
|
+
client.on("connect", () => {
|
|
81
|
+
logger?.info("redis connect");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
client.on("ready", () => {
|
|
85
|
+
logger?.info("redis ready");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
client.on("reconnecting", (delay: number) => {
|
|
89
|
+
logger?.warn(
|
|
90
|
+
delay ? `redis reconnecting in ${delay}ms` : "redis reconnecting",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
client.on("error", (err: Error) => {
|
|
95
|
+
logger?.error("redis error", { err: redactError(err) });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
client.on("close", () => {
|
|
99
|
+
logger?.info("redis close");
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from "./client.js";
|
|
2
|
+
export type { RedisClientOptions, Logger } from "./client.js";
|
|
3
|
+
export { attachEvents, redactError } from "./events.js";
|
|
4
|
+
export { health, type HealthResult, type HealthOptions } from "./check.js";
|
|
5
|
+
export { shutdown } from "./shutdown.js";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { normalizeLogger, type Logger } from "../logger.js";
|
|
2
|
+
import { redactError } from "./events.js";
|
|
3
|
+
import { type Redis as RedisClient } from "ioredis";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How long to wait for QUIT before forcing the connection closed.
|
|
7
|
+
*
|
|
8
|
+
* QUIT is a queued command: ioredis only sends it on a live connection, so a
|
|
9
|
+
* client stuck in `reconnecting` parks it in the offline queue and the promise
|
|
10
|
+
* never settles. Without a bound, `shutdown` hangs for the lifetime of the
|
|
11
|
+
* outage, which is exactly when a process most needs to be able to exit.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
|
+
|
|
15
|
+
const isClosed = (client: RedisClient): boolean =>
|
|
16
|
+
client.status === "end" || FORCED.has(client);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Clients this function already tore down by force.
|
|
20
|
+
*
|
|
21
|
+
* `disconnect()` only reaches ioredis's `closeHandler` from a live connection.
|
|
22
|
+
* On a client sitting in `reconnecting` the connector has nothing to close, so
|
|
23
|
+
* the status never becomes `end` even though `disconnect()` has cleared the
|
|
24
|
+
* retry timer and the connection can never come back. Shutdown is registered on
|
|
25
|
+
* both SIGINT and SIGTERM in most apps, so the second call would otherwise wait
|
|
26
|
+
* out the full QUIT deadline again for a connection that is already gone.
|
|
27
|
+
*
|
|
28
|
+
* A `WeakSet` keeps this bookkeeping off the client object, so it does not
|
|
29
|
+
* show up in `Object.keys`, in a serialised snapshot, or in BullMQ's own
|
|
30
|
+
* inspection of the connection.
|
|
31
|
+
*/
|
|
32
|
+
const FORCED = new WeakSet<object>();
|
|
33
|
+
|
|
34
|
+
export async function shutdown(
|
|
35
|
+
client: RedisClient | null | undefined,
|
|
36
|
+
loggerInput?: Logger,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
if (!client) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Completing the logger here matters more than anywhere else: shutdown runs
|
|
43
|
+
// during teardown, so `logger?.info is not a function` would replace a clean
|
|
44
|
+
// disconnect with a rejection on the way out.
|
|
45
|
+
const logger = normalizeLogger(loggerInput);
|
|
46
|
+
|
|
47
|
+
// Once ioredis reaches "end" the connection is gone for good and QUIT
|
|
48
|
+
// rejects with "Connection is closed.". Shutdown is registered on both
|
|
49
|
+
// SIGINT and SIGTERM in most apps, so a second call has to be a no-op
|
|
50
|
+
// rather than an unhandled rejection during teardown.
|
|
51
|
+
if (isClosed(client)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let timer: NodeJS.Timeout | undefined;
|
|
56
|
+
const expiry = new Promise<"timeout">((resolve) => {
|
|
57
|
+
timer = setTimeout(() => resolve("timeout"), DEFAULT_TIMEOUT_MS);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const outcome = await Promise.race([
|
|
62
|
+
client.quit().then(() => "quit" as const),
|
|
63
|
+
expiry,
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
if (outcome === "timeout") {
|
|
67
|
+
// The server never acknowledged QUIT. Tear the connection down
|
|
68
|
+
// directly so the retry loop stops and the process can exit; the
|
|
69
|
+
// caller asked to disconnect, and we are, one way or another.
|
|
70
|
+
logger?.warn(
|
|
71
|
+
`Redis did not acknowledge QUIT within ${DEFAULT_TIMEOUT_MS}ms, forcing the connection closed`,
|
|
72
|
+
);
|
|
73
|
+
client.disconnect();
|
|
74
|
+
FORCED.add(client);
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
logger?.info("Redis disconnected");
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// A connection that died mid-shutdown is already disconnected from the
|
|
82
|
+
// caller's point of view, so treat it as success but still surface it.
|
|
83
|
+
if (isClosed(client)) {
|
|
84
|
+
logger?.warn(
|
|
85
|
+
"Redis connection closed before QUIT completed",
|
|
86
|
+
redactError(error),
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
logger?.error("Failed to disconnect Redis", redactError(error));
|
|
92
|
+
|
|
93
|
+
throw error;
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/index.ts
ADDED