@holo-js/cache-redis 0.1.3
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/dist/index.d.ts +109 -0
- package/dist/index.mjs +275 -0
- package/package.json +39 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { CacheDriverContract, CacheLockContract } from '@holo-js/cache';
|
|
2
|
+
|
|
3
|
+
type RedisClusterNode = {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly host?: never;
|
|
6
|
+
readonly port?: never;
|
|
7
|
+
} | {
|
|
8
|
+
readonly url?: never;
|
|
9
|
+
readonly host: string;
|
|
10
|
+
readonly port: number;
|
|
11
|
+
};
|
|
12
|
+
type RedisCacheDriverOptions = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly connectionName: string;
|
|
15
|
+
readonly prefix: string;
|
|
16
|
+
readonly redis: {
|
|
17
|
+
readonly username?: string;
|
|
18
|
+
readonly password?: string;
|
|
19
|
+
} & ({
|
|
20
|
+
readonly db: number;
|
|
21
|
+
readonly url?: never;
|
|
22
|
+
readonly clusters?: never;
|
|
23
|
+
readonly socketPath?: never;
|
|
24
|
+
readonly host: string;
|
|
25
|
+
readonly port: number;
|
|
26
|
+
} | {
|
|
27
|
+
readonly db: number;
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly clusters?: never;
|
|
30
|
+
readonly socketPath?: never;
|
|
31
|
+
readonly host?: string;
|
|
32
|
+
readonly port?: number;
|
|
33
|
+
} | {
|
|
34
|
+
readonly db: 0;
|
|
35
|
+
readonly clusters: readonly RedisClusterNode[];
|
|
36
|
+
readonly url?: never;
|
|
37
|
+
readonly socketPath?: never;
|
|
38
|
+
readonly host?: string;
|
|
39
|
+
readonly port?: number;
|
|
40
|
+
} | {
|
|
41
|
+
readonly db: number;
|
|
42
|
+
readonly url?: never;
|
|
43
|
+
readonly clusters?: never;
|
|
44
|
+
readonly socketPath: string;
|
|
45
|
+
readonly host?: never;
|
|
46
|
+
readonly port?: never;
|
|
47
|
+
});
|
|
48
|
+
readonly now?: () => number;
|
|
49
|
+
readonly sleep?: (milliseconds: number) => Promise<void>;
|
|
50
|
+
readonly ownerFactory?: () => string;
|
|
51
|
+
};
|
|
52
|
+
type RedisClientOptions = {
|
|
53
|
+
readonly host?: string;
|
|
54
|
+
readonly port?: number;
|
|
55
|
+
readonly path?: string;
|
|
56
|
+
readonly password?: string;
|
|
57
|
+
readonly username?: string;
|
|
58
|
+
readonly db?: number;
|
|
59
|
+
readonly connectionName?: string;
|
|
60
|
+
readonly lazyConnect: true;
|
|
61
|
+
readonly maxRetriesPerRequest: number;
|
|
62
|
+
readonly tls?: Record<string, never>;
|
|
63
|
+
};
|
|
64
|
+
type RedisClusterStartupNode = {
|
|
65
|
+
readonly host: string;
|
|
66
|
+
readonly port: number;
|
|
67
|
+
readonly tls?: Record<string, never>;
|
|
68
|
+
};
|
|
69
|
+
type RedisClusterOptions = {
|
|
70
|
+
readonly redisOptions: RedisClientOptions;
|
|
71
|
+
};
|
|
72
|
+
type RedisClientLike = {
|
|
73
|
+
readonly isCluster?: boolean;
|
|
74
|
+
get(key: string): Promise<string | null>;
|
|
75
|
+
set(key: string, value: string, ...arguments_: readonly (string | number)[]): Promise<'OK' | null>;
|
|
76
|
+
del(...keys: string[]): Promise<number>;
|
|
77
|
+
scan(cursor: string, matchLabel: string, pattern: string, countLabel: string, count: number): Promise<[string, string[]]>;
|
|
78
|
+
incrby(key: string, amount: number): Promise<number>;
|
|
79
|
+
decrby(key: string, amount: number): Promise<number>;
|
|
80
|
+
eval(script: string, numberOfKeys: number, ...arguments_: readonly string[]): Promise<number>;
|
|
81
|
+
nodes?(role: 'master'): readonly RedisClientLike[];
|
|
82
|
+
};
|
|
83
|
+
declare function isRedisSocketConnectionTarget(value: string): boolean;
|
|
84
|
+
declare function toRedisSocketPath(value: string): string;
|
|
85
|
+
declare function escapeRedisGlob(value: string): string;
|
|
86
|
+
declare function createRedisClientOptions(options: RedisCacheDriverOptions): RedisClientOptions;
|
|
87
|
+
declare function parseClusterNodeUrl(url: string, label: string): RedisClusterStartupNode;
|
|
88
|
+
declare function resolveClusterStartupNodes(options: RedisCacheDriverOptions): readonly RedisClusterStartupNode[];
|
|
89
|
+
declare function createRedisClusterOptions(options: RedisCacheDriverOptions): RedisClusterOptions;
|
|
90
|
+
declare function createRedisClient(options: RedisCacheDriverOptions): RedisClientLike;
|
|
91
|
+
declare function toLockTtlMilliseconds(seconds: number): number;
|
|
92
|
+
declare function isRedisNumericMutationError(error: unknown): error is Error;
|
|
93
|
+
declare function createRedisLock(client: RedisClientLike, name: string, seconds: number, ownerFactory: () => string, sleep: (milliseconds: number) => Promise<void>, now: () => number): CacheLockContract;
|
|
94
|
+
declare function createRedisCacheDriver(options: RedisCacheDriverOptions): CacheDriverContract;
|
|
95
|
+
declare const redisCacheDriverInternals: {
|
|
96
|
+
createRedisClient: typeof createRedisClient;
|
|
97
|
+
createRedisClientOptions: typeof createRedisClientOptions;
|
|
98
|
+
createRedisClusterOptions: typeof createRedisClusterOptions;
|
|
99
|
+
createRedisLock: typeof createRedisLock;
|
|
100
|
+
escapeRedisGlob: typeof escapeRedisGlob;
|
|
101
|
+
isRedisSocketConnectionTarget: typeof isRedisSocketConnectionTarget;
|
|
102
|
+
isRedisNumericMutationError: typeof isRedisNumericMutationError;
|
|
103
|
+
parseClusterNodeUrl: typeof parseClusterNodeUrl;
|
|
104
|
+
resolveClusterStartupNodes: typeof resolveClusterStartupNodes;
|
|
105
|
+
toLockTtlMilliseconds: typeof toLockTtlMilliseconds;
|
|
106
|
+
toRedisSocketPath: typeof toRedisSocketPath;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export { type RedisCacheDriverOptions, createRedisCacheDriver, redisCacheDriverInternals };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import Redis from "ioredis";
|
|
4
|
+
import {
|
|
5
|
+
CacheInvalidNumericMutationError
|
|
6
|
+
} from "@holo-js/cache";
|
|
7
|
+
var REDIS_SCAN_COUNT = 100;
|
|
8
|
+
var RELEASE_LOCK_SCRIPT = [
|
|
9
|
+
'if redis.call("get", KEYS[1]) == ARGV[1] then',
|
|
10
|
+
' return redis.call("del", KEYS[1])',
|
|
11
|
+
"end",
|
|
12
|
+
"return 0"
|
|
13
|
+
].join("\n");
|
|
14
|
+
function defaultSleep(milliseconds) {
|
|
15
|
+
return new Promise((resolveDelay) => {
|
|
16
|
+
setTimeout(resolveDelay, milliseconds);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function isRedisSocketConnectionTarget(value) {
|
|
20
|
+
return value.startsWith("unix://") || value.startsWith("/");
|
|
21
|
+
}
|
|
22
|
+
function toRedisSocketPath(value) {
|
|
23
|
+
return value.startsWith("unix://") ? value.slice("unix://".length) : value;
|
|
24
|
+
}
|
|
25
|
+
function escapeRedisGlob(value) {
|
|
26
|
+
return value.replace(/[\\*?[\]]/g, (match) => `\\${match}`);
|
|
27
|
+
}
|
|
28
|
+
function isRedisUrl(value) {
|
|
29
|
+
return value.startsWith("redis://") || value.startsWith("rediss://");
|
|
30
|
+
}
|
|
31
|
+
function createRedisClientOptions(options) {
|
|
32
|
+
return {
|
|
33
|
+
password: options.redis.password,
|
|
34
|
+
username: options.redis.username,
|
|
35
|
+
db: options.redis.db,
|
|
36
|
+
...typeof options.redis.url === "undefined" && !options.redis.clusters?.length && typeof options.redis.socketPath === "string" ? { path: options.redis.socketPath } : typeof options.redis.url === "undefined" && !options.redis.clusters?.length && typeof options.redis.host === "string" && isRedisSocketConnectionTarget(options.redis.host) ? { path: toRedisSocketPath(options.redis.host) } : typeof options.redis.url === "undefined" && !options.redis.clusters?.length ? {
|
|
37
|
+
host: options.redis.host,
|
|
38
|
+
port: options.redis.port
|
|
39
|
+
} : {},
|
|
40
|
+
...typeof options.redis.url === "undefined" && !options.redis.clusters?.length && !isRedisUrl(options.connectionName) ? { connectionName: options.connectionName } : {},
|
|
41
|
+
lazyConnect: true,
|
|
42
|
+
maxRetriesPerRequest: 3
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function parseClusterNodeUrl(url, label) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(url);
|
|
48
|
+
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
|
|
49
|
+
throw new Error(`unsupported protocol "${parsed.protocol}"`);
|
|
50
|
+
}
|
|
51
|
+
if (!parsed.hostname) {
|
|
52
|
+
throw new Error("missing hostname");
|
|
53
|
+
}
|
|
54
|
+
if (parsed.username || parsed.password || parsed.pathname && parsed.pathname !== "/") {
|
|
55
|
+
throw new Error("cluster node URLs must not include credentials or a Redis database/path; supply those via redisOptions instead");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
host: parsed.hostname,
|
|
59
|
+
port: parsed.port ? Number.parseInt(parsed.port, 10) : 6379,
|
|
60
|
+
...parsed.protocol === "rediss:" ? { tls: {} } : {}
|
|
61
|
+
};
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[@holo-js/cache-redis] ${label} is invalid: ${error instanceof Error ? error.message : String(error)}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function resolveClusterStartupNodes(options) {
|
|
69
|
+
return (options.redis.clusters ?? []).map((node, index) => {
|
|
70
|
+
const label = `Cache Redis cluster node ${index + 1}`;
|
|
71
|
+
if (typeof node.url === "string") {
|
|
72
|
+
return parseClusterNodeUrl(node.url, `${label} url`);
|
|
73
|
+
}
|
|
74
|
+
const socketPath = "socketPath" in node ? node.socketPath : void 0;
|
|
75
|
+
if (typeof socketPath === "string" || isRedisSocketConnectionTarget(node.host)) {
|
|
76
|
+
throw new Error(`[@holo-js/cache-redis] ${label} cannot use a Unix socket path in Redis cluster mode.`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
host: node.host,
|
|
80
|
+
port: node.port
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function createRedisClusterOptions(options) {
|
|
85
|
+
if (options.redis.db !== 0) {
|
|
86
|
+
throw new Error("[@holo-js/cache-redis] Redis Cluster does not support selecting a non-zero database. Remove redis.db or set it to 0.");
|
|
87
|
+
}
|
|
88
|
+
const startupNodes = resolveClusterStartupNodes(options);
|
|
89
|
+
return {
|
|
90
|
+
redisOptions: {
|
|
91
|
+
password: options.redis.password,
|
|
92
|
+
username: options.redis.username,
|
|
93
|
+
lazyConnect: true,
|
|
94
|
+
maxRetriesPerRequest: 3,
|
|
95
|
+
...startupNodes.some((node) => typeof node.tls !== "undefined") ? { tls: {} } : {}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function createRedisClient(options) {
|
|
100
|
+
const RedisConstructor = Redis;
|
|
101
|
+
const clientOptions = createRedisClientOptions(options);
|
|
102
|
+
const hasStandaloneUrl = typeof options.redis.url === "string";
|
|
103
|
+
const hasClusters = !!options.redis.clusters?.length;
|
|
104
|
+
if (hasStandaloneUrl && hasClusters) {
|
|
105
|
+
throw new Error("[@holo-js/cache-redis] Configure either redis.url or redis.clusters, but not both.");
|
|
106
|
+
}
|
|
107
|
+
if (hasStandaloneUrl) {
|
|
108
|
+
return new RedisConstructor(options.redis.url, clientOptions);
|
|
109
|
+
}
|
|
110
|
+
if (hasClusters) {
|
|
111
|
+
return new RedisConstructor.Cluster(
|
|
112
|
+
resolveClusterStartupNodes(options),
|
|
113
|
+
createRedisClusterOptions(options)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return new RedisConstructor(clientOptions);
|
|
117
|
+
}
|
|
118
|
+
function toLockTtlMilliseconds(seconds) {
|
|
119
|
+
return Math.max(1, Math.round(seconds * 1e3));
|
|
120
|
+
}
|
|
121
|
+
function isRedisNumericMutationError(error) {
|
|
122
|
+
return error instanceof Error && (error.message.includes("value is not an integer or out of range") || error.message.includes("WRONGTYPE"));
|
|
123
|
+
}
|
|
124
|
+
function createRedisLock(client, name, seconds, ownerFactory, sleep, now) {
|
|
125
|
+
const owner = ownerFactory();
|
|
126
|
+
async function tryAcquire() {
|
|
127
|
+
return await client.set(name, owner, "PX", toLockTtlMilliseconds(seconds), "NX") === "OK";
|
|
128
|
+
}
|
|
129
|
+
async function withCallback(callback) {
|
|
130
|
+
if (!callback) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return await callback();
|
|
135
|
+
} finally {
|
|
136
|
+
await lock.release();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const lock = {
|
|
140
|
+
name,
|
|
141
|
+
async get(callback) {
|
|
142
|
+
if (!await tryAcquire()) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return withCallback(callback);
|
|
146
|
+
},
|
|
147
|
+
async release() {
|
|
148
|
+
return await client.eval(RELEASE_LOCK_SCRIPT, 1, name, owner) === 1;
|
|
149
|
+
},
|
|
150
|
+
async block(waitSeconds, callback) {
|
|
151
|
+
const deadline = now() + Math.max(0, Math.round(waitSeconds * 1e3));
|
|
152
|
+
while (true) {
|
|
153
|
+
if (await tryAcquire()) {
|
|
154
|
+
return withCallback(callback);
|
|
155
|
+
}
|
|
156
|
+
if (now() >= deadline) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
await sleep(10);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
return lock;
|
|
164
|
+
}
|
|
165
|
+
function createRedisCacheDriver(options) {
|
|
166
|
+
const client = createRedisClient(options);
|
|
167
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
168
|
+
const ownerFactory = options.ownerFactory ?? randomUUID;
|
|
169
|
+
const now = options.now ?? Date.now;
|
|
170
|
+
const flushPattern = `${escapeRedisGlob(options.prefix)}*`;
|
|
171
|
+
async function flushClient(target) {
|
|
172
|
+
let cursor = "0";
|
|
173
|
+
do {
|
|
174
|
+
const [nextCursor, keys] = await target.scan(cursor, "MATCH", flushPattern, "COUNT", REDIS_SCAN_COUNT);
|
|
175
|
+
cursor = nextCursor;
|
|
176
|
+
if (keys.length > 0) {
|
|
177
|
+
await Promise.all(keys.map(async (key) => target.del(key)));
|
|
178
|
+
}
|
|
179
|
+
} while (cursor !== "0");
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
name: options.name,
|
|
183
|
+
driver: "redis",
|
|
184
|
+
async get(key) {
|
|
185
|
+
const payload = await client.get(key);
|
|
186
|
+
if (payload === null) {
|
|
187
|
+
return Object.freeze({ hit: false });
|
|
188
|
+
}
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
hit: true,
|
|
191
|
+
payload
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
async put(input) {
|
|
195
|
+
if (typeof input.expiresAt === "number" && input.expiresAt <= now()) {
|
|
196
|
+
await client.del(input.key);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (typeof input.expiresAt === "number") {
|
|
200
|
+
await client.set(input.key, input.payload, "PXAT", input.expiresAt);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
await client.set(input.key, input.payload);
|
|
204
|
+
return true;
|
|
205
|
+
},
|
|
206
|
+
async add(input) {
|
|
207
|
+
if (typeof input.expiresAt === "number" && input.expiresAt <= now()) {
|
|
208
|
+
await client.del(input.key);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (typeof input.expiresAt === "number") {
|
|
212
|
+
return await client.set(input.key, input.payload, "PXAT", input.expiresAt, "NX") === "OK";
|
|
213
|
+
}
|
|
214
|
+
return await client.set(input.key, input.payload, "NX") === "OK";
|
|
215
|
+
},
|
|
216
|
+
async forget(key) {
|
|
217
|
+
return await client.del(key) > 0;
|
|
218
|
+
},
|
|
219
|
+
async flush() {
|
|
220
|
+
if (client.isCluster) {
|
|
221
|
+
for (const node of client.nodes?.("master") ?? []) {
|
|
222
|
+
await flushClient(node);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await flushClient(client);
|
|
227
|
+
},
|
|
228
|
+
async increment(key, amount) {
|
|
229
|
+
try {
|
|
230
|
+
return await client.incrby(key, amount);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (!isRedisNumericMutationError(error)) {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
throw new CacheInvalidNumericMutationError(
|
|
236
|
+
`[@holo-js/cache] Cache key "${key}" does not contain a numeric value.`,
|
|
237
|
+
{ cause: error }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async decrement(key, amount) {
|
|
242
|
+
try {
|
|
243
|
+
return await client.decrby(key, amount);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (!isRedisNumericMutationError(error)) {
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
throw new CacheInvalidNumericMutationError(
|
|
249
|
+
`[@holo-js/cache] Cache key "${key}" does not contain a numeric value.`,
|
|
250
|
+
{ cause: error }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
lock(name, seconds) {
|
|
255
|
+
return createRedisLock(client, name, seconds, ownerFactory, sleep, now);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
var redisCacheDriverInternals = {
|
|
260
|
+
createRedisClient,
|
|
261
|
+
createRedisClientOptions,
|
|
262
|
+
createRedisClusterOptions,
|
|
263
|
+
createRedisLock,
|
|
264
|
+
escapeRedisGlob,
|
|
265
|
+
isRedisSocketConnectionTarget,
|
|
266
|
+
isRedisNumericMutationError,
|
|
267
|
+
parseClusterNodeUrl,
|
|
268
|
+
resolveClusterStartupNodes,
|
|
269
|
+
toLockTtlMilliseconds,
|
|
270
|
+
toRedisSocketPath
|
|
271
|
+
};
|
|
272
|
+
export {
|
|
273
|
+
createRedisCacheDriver,
|
|
274
|
+
redisCacheDriverInternals
|
|
275
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holo-js/cache-redis",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Holo-JS Framework - Redis cache driver",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.mjs",
|
|
11
|
+
"default": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.mjs",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"stub": "tsup",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "vitest --run"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@holo-js/cache": "^0.1.3"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"ioredis": "catalog:",
|
|
30
|
+
"tslib": "^2.8.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@holo-js/cache": "workspace:*",
|
|
34
|
+
"@types/node": "^22.10.2",
|
|
35
|
+
"tsup": "^8.3.5",
|
|
36
|
+
"typescript": "^5.7.2",
|
|
37
|
+
"vitest": "^2.1.8"
|
|
38
|
+
}
|
|
39
|
+
}
|