diff --git a/README.md b/README.md index 6442883..dcf2686 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,26 @@ const redisCacheHandler = createRedisHandler({ **Note:** Redis Cluster support is currently experimental and may have limitations or unexpected bugs. Use it with caution. +### Using ioredis + +If you prefer using `ioredis` instead of `@redis/client`, you can use the `ioredisAdapter` helper. + +`npm i ioredis` + +```js +import Redis from "ioredis"; +import createRedisHandler from "@fortedigital/nextjs-cache-handler/redis-strings"; +import { ioredisAdapter } from "@fortedigital/nextjs-cache-handler/helpers/ioredisAdapter"; + +const client = new Redis(process.env.REDIS_URL); +const redisClient = ioredisAdapter(client); + +const redisHandler = createRedisHandler({ + client: redisClient, + keyPrefix: "my-app:", +}); +``` + --- ### `local-lru` diff --git a/packages/nextjs-cache-handler/package.json b/packages/nextjs-cache-handler/package.json index 9e7db14..5e473fa 100644 --- a/packages/nextjs-cache-handler/package.json +++ b/packages/nextjs-cache-handler/package.json @@ -50,6 +50,10 @@ "./helpers/withAbortSignalProxy": { "require": "./dist/helpers/withAbortSignalProxy.cjs", "import": "./dist/helpers/withAbortSignalProxy.js" + }, + "./helpers/ioredisAdapter": { + "require": "./dist/helpers/ioredisAdapter.cjs", + "import": "./dist/helpers/ioredisAdapter.js" } }, "typesVersions": { @@ -106,6 +110,7 @@ "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.2", "globals": "^16.5.0", + "ioredis": "^5.8.2", "jest": "^30.2.0", "prettier": "^3.7.4", "prettier-plugin-packagejson": "2.5.20", @@ -118,8 +123,14 @@ }, "peerDependencies": { "@redis/client": ">= 5.5.6", + "ioredis": ">= 5.0.0", "next": ">=15.2.4" }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } + }, "distTags": [ "next15" ], diff --git a/packages/nextjs-cache-handler/src/helpers/ioredisAdapter.ts b/packages/nextjs-cache-handler/src/helpers/ioredisAdapter.ts new file mode 100644 index 0000000..85d64fa --- /dev/null +++ b/packages/nextjs-cache-handler/src/helpers/ioredisAdapter.ts @@ -0,0 +1,137 @@ +import type { RedisClientType } from "@redis/client"; +import type { Redis } from "ioredis"; + +/** + * Adapter to make an ioredis client compatible with the interface expected by createRedisHandler. + * + * @param client - The ioredis client instance. + * @returns A proxy that implements the subset of RedisClientType used by this library. + */ +export function ioredisAdapter(client: Redis): RedisClientType { + return new Proxy(client, { + get(target, prop, receiver) { + if (prop === "isReady") { + return target.status === "ready"; + } + + if (prop === "hScan") { + return async ( + key: string, + cursor: string, + options?: { COUNT?: number }, + ) => { + let result: [string, string[]]; + + if (options?.COUNT) { + result = await target.hscan(key, cursor, "COUNT", options.COUNT); + } else { + result = await target.hscan(key, cursor); + } + + const [newCursor, items] = result; + + const entries = []; + for (let i = 0; i < items.length; i += 2) { + entries.push({ field: items[i], value: items[i + 1] }); + } + + return { + cursor: newCursor, + entries, + }; + }; + } + + if (prop === "hDel") { + return async (key: string, fields: string | string[]) => { + const args = Array.isArray(fields) ? fields : [fields]; + if (args.length === 0) { + return 0; + } + return target.hdel(key, ...args); + }; + } + + if (prop === "unlink") { + return async (keys: string | string[]) => { + const args = Array.isArray(keys) ? keys : [keys]; + if (args.length === 0) { + return 0; + } + return target.unlink(...args); + }; + } + + if (prop === "set") { + return async (key: string, value: string, options?: any) => { + const args: (string | number)[] = [key, value]; + if (options) { + if (options.EXAT) { + args.push("EXAT", options.EXAT); + } else if (options.PXAT) { + args.push("PXAT", options.PXAT); + } else if (options.EX) { + args.push("EX", options.EX); + } else if (options.PX) { + args.push("PX", options.PX); + } else if (options.KEEPTTL) { + args.push("KEEPTTL"); + } + // Add other options if necessary + } + // Cast to a generic signature to avoid overload mismatch issues with dynamic args + const setFn = target.set as unknown as ( + key: string, + value: string | number, + ...args: (string | number)[] + ) => Promise; + + return setFn( + args[0] as string, + args[1] as string | number, + ...args.slice(2), + ); + }; + } + + if (prop === "hmGet") { + return async (key: string, fields: string | string[]) => { + const args = Array.isArray(fields) ? fields : [fields]; + if (args.length === 0) { + return []; + } + return target.hmget(key, ...args); + }; + } + + // Handle camelCase to lowercase mapping for other methods + if (typeof prop === "string") { + // Special case for expireAt -> expireat + if (prop === "expireAt") { + return target.expireat.bind(target); + } + + // hSet -> hset + if (prop === "hSet") { + return target.hset.bind(target); + } + + // hExists -> hexists + if (prop === "hExists") { + return target.hexists.bind(target); + } + + // Default fallback to lowercase if exists + const lowerProp = prop.toLowerCase(); + if ( + lowerProp in target && + typeof (target as any)[lowerProp] === "function" + ) { + return (target as any)[lowerProp].bind(target); + } + } + + return Reflect.get(target, prop, receiver); + }, + }) as unknown as RedisClientType; +} diff --git a/packages/nextjs-cache-handler/tsup.config.ts b/packages/nextjs-cache-handler/tsup.config.ts index 427c2d1..8a2f41a 100644 --- a/packages/nextjs-cache-handler/tsup.config.ts +++ b/packages/nextjs-cache-handler/tsup.config.ts @@ -9,6 +9,7 @@ export const tsup = defineConfig({ "src/helpers/redisClusterAdapter.ts", "src/helpers/withAbortSignal.ts", "src/helpers/withAbortSignalProxy.ts", + "src/helpers/ioredisAdapter.ts", ], splitting: false, outDir: "dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9efbc4f..55e2ce5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: link:../../packages/nextjs-cache-handler next: specifier: ^15.5.6 - version: 15.5.6(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: specifier: ^19.2.0 version: 19.2.0 @@ -94,6 +94,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + ioredis: + specifier: ^5.8.2 + version: 5.8.2 jest: specifier: ^30.2.0 version: 30.2.0(@types/node@22.16.5) @@ -519,6 +522,7 @@ packages: '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -526,6 +530,7 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} @@ -668,6 +673,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1713,6 +1721,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + detect-indent@7.0.2: resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} engines: {node: '>=12.20'} @@ -1912,6 +1924,7 @@ packages: eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true eslint@9.39.1: @@ -2203,6 +2216,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2211,6 +2225,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2654,6 +2672,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -2752,6 +2776,7 @@ packages: next@15.5.6: resolution: {integrity: sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -2997,6 +3022,14 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redis@5.9.0: resolution: {integrity: sha512-E8dQVLSyH6UE/C9darFuwq4usOPrqfZ1864kI4RFbr5Oj9ioB9qPF0oJMwX7s8mf6sPYrz84x/Dx1PGF3/0EaQ==} engines: {node: '>= 18'} @@ -3167,6 +3200,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4026,6 +4062,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.4.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -5139,6 +5177,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + denque@2.1.0: {} + detect-indent@7.0.2: {} detect-libc@2.1.2: {} @@ -5852,6 +5892,20 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ioredis@5.8.2: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -6487,6 +6541,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} @@ -6592,6 +6650,29 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@next/env': 15.5.6 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001754 + postcss: 8.4.31 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + styled-jsx: 5.1.6(react@19.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.6 + '@next/swc-darwin-x64': 15.5.6 + '@next/swc-linux-arm64-gnu': 15.5.6 + '@next/swc-linux-arm64-musl': 15.5.6 + '@next/swc-linux-x64-gnu': 15.5.6 + '@next/swc-linux-x64-musl': 15.5.6 + '@next/swc-win32-arm64-msvc': 15.5.6 + '@next/swc-win32-x64-msvc': 15.5.6 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-int64@0.4.0: {} node-releases@2.0.27: {} @@ -6804,6 +6885,12 @@ snapshots: readdirp@4.1.2: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redis@5.9.0: dependencies: '@redis/bloom': 5.9.0(@redis/client@5.9.0) @@ -7049,6 +7136,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + standard-as-callback@2.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -7144,6 +7233,11 @@ snapshots: optionalDependencies: '@babel/core': 7.28.5 + styled-jsx@5.1.6(react@19.2.0): + dependencies: + client-only: 0.0.1 + react: 19.2.0 + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.13