From feb50959fcf226409e22ec005804dfd0d56a619c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:39:21 +0000 Subject: [PATCH 1/2] Initial plan From 05591c845452c8720f8f43bbf2c0fd7d6ec69ef6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:50:45 +0000 Subject: [PATCH 2/2] Implement Redis cache for server Co-authored-by: thezzisu <21094314+thezzisu@users.noreply.github.com> --- packages/server/REDIS_CACHE.md | 57 ++++++++++++++++++ packages/server/package.json | 1 + packages/server/src/cache/index.ts | 12 +++- packages/server/src/cache/redis.ts | 93 +++++++++++++++++++++++++++++ packages/server/src/config/index.ts | 4 +- yarn.lock | 61 ++++++++++++++++++- 6 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 packages/server/REDIS_CACHE.md create mode 100644 packages/server/src/cache/redis.ts diff --git a/packages/server/REDIS_CACHE.md b/packages/server/REDIS_CACHE.md new file mode 100644 index 0000000..0efc2ca --- /dev/null +++ b/packages/server/REDIS_CACHE.md @@ -0,0 +1,57 @@ +# Redis Cache Implementation + +This implementation adds Redis support to the UAAA server cache system. + +## Configuration + +Add the following to your configuration file to use Redis cache: + +```json +{ + "cacheType": "redis", + "redisUrl": "redis://localhost:6379" +} +``` + +Configuration options: +- `cacheType`: Either "mongo" (default) or "redis" +- `redisUrl`: Redis connection URL (optional, defaults to `redis://localhost:6379` or `REDIS_URL` environment variable) + +## Environment Variables + +You can also configure Redis using environment variables: +- `REDIS_URL`: Redis connection URL + +## Example Configuration + +For development with Docker Compose: +```json +{ + "appId": "uaaa.local", + "mongoUri": "mongodb://localhost:27017/uaaa", + "cacheType": "redis", + "redisUrl": "redis://localhost:6379", + "plugins": [], + "port": 3000, + "deploymentUrl": "http://localhost:3000", + "jwtTimeout": "1h", + "refreshTimeout": "7d", + "tokenTimeout": "10m" +} +``` + +For production: +```json +{ + "appId": "uaaa.prod", + "mongoUri": "mongodb://mongo:27017/uaaa", + "cacheType": "redis", + "redisUrl": "redis://redis:6379", + "plugins": [], + "port": 3000, + "deploymentUrl": "https://your-domain.com", + "jwtTimeout": "1h", + "refreshTimeout": "7d", + "tokenTimeout": "10m" +} +``` \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 8aaa172..170783a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -37,6 +37,7 @@ "nanoid": "^5.1.5", "nodemailer": "^7.0.5", "pino": "^9.9.0", + "redis": "^5.8.2", "totp-generator": "^1.0.0", "typanion": "^3.14.0" }, diff --git a/packages/server/src/cache/index.ts b/packages/server/src/cache/index.ts index af924f6..e55322a 100644 --- a/packages/server/src/cache/index.ts +++ b/packages/server/src/cache/index.ts @@ -2,6 +2,7 @@ import { Hookable } from 'hookable' import type { App } from '../index.js' import { CacheImpl } from './_common.js' import { MongoCache } from './mongo.js' +import { RedisCache } from './redis.js' export interface CacheManager extends Omit {} @@ -13,7 +14,15 @@ export class CacheManager extends Hookable { } async initCache() { - this.impl ??= new MongoCache(this) + if (!this.impl) { + const cacheType = this.app.config.get('cacheType') || 'mongo' + if (cacheType === 'redis') { + const redisUrl = this.app.config.get('redisUrl') + this.impl = new RedisCache(this, redisUrl) + } else { + this.impl = new MongoCache(this) + } + } await this.impl.init?.() this.set = this.impl.set.bind(this.impl) this.setx = this.impl.setx.bind(this.impl) @@ -37,3 +46,4 @@ export class CacheManager extends Hookable { export * from './_common.js' export * from './mongo.js' +export * from './redis.js' diff --git a/packages/server/src/cache/redis.ts b/packages/server/src/cache/redis.ts new file mode 100644 index 0000000..63e18ae --- /dev/null +++ b/packages/server/src/cache/redis.ts @@ -0,0 +1,93 @@ +import { createClient, type RedisClientType } from 'redis' +import { CacheImpl } from './_common.js' +import type { CacheManager } from './index.js' + +export class RedisCache extends CacheImpl { + client!: RedisClientType + + constructor(manager: CacheManager, private connectionUrl?: string) { + super(manager) + } + + async init(): Promise { + this.client = createClient({ + url: this.connectionUrl || process.env.REDIS_URL || 'redis://localhost:6379' + }) + + this.client.on('error', (err) => { + console.error('Redis Client Error', err) + }) + + await this.client.connect() + } + + async disconnect(): Promise { + if (this.client) { + await this.client.disconnect() + } + } + + override async set(key: string, value: string, expiresIn: number): Promise { + await this.client.setEx(key, Math.ceil(expiresIn / 1000), value) + } + + override async get(key: string): Promise { + return await this.client.get(key) + } + + override async del(key: string): Promise { + await this.client.del(key) + } + + override async ttl(key: string): Promise { + const ttlSeconds = await this.client.ttl(key) + if (ttlSeconds === -2) return -2 // Key does not exist + if (ttlSeconds === -1) return -1 // Key exists but has no expiration + return ttlSeconds * 1000 // Convert to milliseconds + } + + override async clear(): Promise { + await this.client.flushDb() + } + + override async setn(key: string, value: number, expiresIn: number): Promise { + await this.client.setEx(key, Math.ceil(expiresIn / 1000), value.toString()) + } + + override async getn(key: string): Promise { + const value = await this.client.get(key) + if (value === null) return 0 + const parsed = parseInt(value, 10) + return isNaN(parsed) ? 0 : parsed + } + + override async incr(key: string, amount: number, expiresIn: number): Promise { + // Use a transaction to ensure atomicity + const multi = this.client.multi() + multi.incrBy(key, amount) + multi.expire(key, Math.ceil(expiresIn / 1000)) + const results = await multi.exec() + + if (!results || results.length < 1) return 0 + const result = results[0] as unknown + return typeof result === 'number' ? result : 0 + } + + override async expire(key: string, timeout: number): Promise { + await this.client.expire(key, Math.ceil(timeout / 1000)) + } + + override async setx(key: string, value: T, expiresIn: number): Promise { + await this.client.setEx(key, Math.ceil(expiresIn / 1000), JSON.stringify(value)) + } + + override async getx(key: string): Promise { + const value = await this.client.get(key) + if (value === null) return null + try { + return JSON.parse(value) as T + } catch { + return null + } + } +} \ No newline at end of file diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index db5908c..80137ee 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -16,7 +16,9 @@ const tAppConfig = type({ 'trustedUpstreamIssuers?': 'string[]', 'openidClaimConfig?': type.Record('string', type({ alias: 'string', 'verifiable?': 'boolean' })), 'openidAdditionalClaims?': 'Record', - 'uiPath?': 'string' + 'uiPath?': 'string', + 'cacheType?': '"mongo" | "redis"', + 'redisUrl?': 'string' }) type IAppConfig = typeof tAppConfig.infer diff --git a/yarn.lock b/yarn.lock index 8554893..1431ed4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3540,6 +3540,51 @@ __metadata: languageName: node linkType: hard +"@redis/bloom@npm:5.8.2": + version: 5.8.2 + resolution: "@redis/bloom@npm:5.8.2" + peerDependencies: + "@redis/client": ^5.8.2 + checksum: 10/99ec4f127b6ce86a62d8ddde9a2a3631feb0c68abe8c4c2e4cea143c1d1e2cec5fe78f0ad79b8cabc2bc0007f4718d6bf3b253526c704bc0e2111c9ef3b28bf9 + languageName: node + linkType: hard + +"@redis/client@npm:5.8.2": + version: 5.8.2 + resolution: "@redis/client@npm:5.8.2" + dependencies: + cluster-key-slot: "npm:1.1.2" + checksum: 10/653ba2d0efb97b4479b3e9d2c1038da1993191a863f8c0427975f63a66354638b02befc2375977a0d0906a5bd70c0f57982e905063079c0c294944c519d87c88 + languageName: node + linkType: hard + +"@redis/json@npm:5.8.2": + version: 5.8.2 + resolution: "@redis/json@npm:5.8.2" + peerDependencies: + "@redis/client": ^5.8.2 + checksum: 10/2877cd93b717344f1d1215474932bf2a662a069970356e781dc0f45f2fb4d1941c49401db33a34e9ab9347c485bba22bc97b57ddf2185e83f53ee8320968dedf + languageName: node + linkType: hard + +"@redis/search@npm:5.8.2": + version: 5.8.2 + resolution: "@redis/search@npm:5.8.2" + peerDependencies: + "@redis/client": ^5.8.2 + checksum: 10/6cc0499a11e86ea28451c02dc5b4b3133fc32e53447e4aebf0d100b0820ba2bcccacab9f344192b6a672b56851a4053705099482f26e4639f8fc3031f87a1cc6 + languageName: node + linkType: hard + +"@redis/time-series@npm:5.8.2": + version: 5.8.2 + resolution: "@redis/time-series@npm:5.8.2" + peerDependencies: + "@redis/client": ^5.8.2 + checksum: 10/a7758173807b1464433bb3c18714b53acbc26f7a9c91d4cb3530bb8cfd85bdb60bcb4e67057ec9b053f5c64408beab4d4d3440689e6850762f9edc7e2e527231 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.29": version: 1.0.0-beta.29 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.29" @@ -4990,6 +5035,7 @@ __metadata: nanoid: "npm:^5.1.5" nodemailer: "npm:^7.0.5" pino: "npm:^9.9.0" + redis: "npm:^5.8.2" totp-generator: "npm:^1.0.0" typanion: "npm:^3.14.0" typescript: "npm:^5.9.2" @@ -6730,7 +6776,7 @@ __metadata: languageName: node linkType: hard -"cluster-key-slot@npm:^1.1.0": +"cluster-key-slot@npm:1.1.2, cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" checksum: 10/516ed8b5e1a14d9c3a9c96c72ef6de2d70dfcdbaa0ec3a90bc7b9216c5457e39c09a5775750c272369070308542e671146120153062ab5f2f481bed5de2c925f @@ -12089,6 +12135,19 @@ __metadata: languageName: node linkType: hard +"redis@npm:^5.8.2": + version: 5.8.2 + resolution: "redis@npm:5.8.2" + dependencies: + "@redis/bloom": "npm:5.8.2" + "@redis/client": "npm:5.8.2" + "@redis/json": "npm:5.8.2" + "@redis/search": "npm:5.8.2" + "@redis/time-series": "npm:5.8.2" + checksum: 10/a7635cedf25f449ecf4ee667067cff655b6d462a93225a6e93217c3fdb3a050cf503f6add9ccaedb6caaced5e80c36ff754bea8ed4b270f2a1006add041bd219 + languageName: node + linkType: hard + "regex-recursion@npm:^6.0.2": version: 6.0.2 resolution: "regex-recursion@npm:6.0.2"