diff --git a/package.json b/package.json index 13a818111..89715dd5b 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "@svgr/cli": "^8.1.0", "@tanstack/react-query": "^5.90.21", "@umami/react-zen": "^0.245.0", - "@umami/redis-client": "^0.30.0", "bcryptjs": "^3.0.2", "chalk": "^5.6.2", "chart.js": "^4.5.1", @@ -110,6 +109,7 @@ "react-simple-maps": "^2.3.0", "react-use-measure": "^2.0.4", "react-window": "^1.8.6", + "redis": "^4.5.1", "request-ip": "^3.3.0", "semver": "^7.7.4", "serialize-error": "^12.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77edaf4dd..2968b7535 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: '@umami/react-zen': specifier: ^0.245.0 version: 0.245.0(@types/react@19.2.14)(immer@10.2.0)(react-aria-components@1.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.18)(use-sync-external-store@1.6.0(react@19.2.4)) - '@umami/redis-client': - specifier: ^0.30.0 - version: 0.30.0 bcryptjs: specifier: ^3.0.2 version: 3.0.3 @@ -173,6 +170,9 @@ importers: react-window: specifier: ^1.8.6 version: 1.8.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + redis: + specifier: ^4.5.1 + version: 4.7.1 request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -328,8 +328,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - dist: {} - packages: '@ampproject/remapping@2.3.0': @@ -2965,9 +2963,6 @@ packages: react-aria-components: ^1.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@umami/redis-client@0.30.0': - resolution: {integrity: sha512-pqeMPdEFMH+9GDpiQd5MdRdTppif4vPl/sp8Y9hPY277g/UFlJAbCUJluESmZRBHjFdXtBPtLzQkxjvdjlRzuQ==} - acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -10062,13 +10057,6 @@ snapshots: - tailwindcss - use-sync-external-store - '@umami/redis-client@0.30.0': - dependencies: - debug: 4.4.3(supports-color@8.1.1) - redis: 4.7.1 - transitivePeerDependencies: - - supports-color - acorn-walk@8.3.4: dependencies: acorn: 8.15.0 diff --git a/src/lib/redis.ts b/src/lib/redis.ts index edde3d653..71a95a5ef 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -1,10 +1,112 @@ -import { UmamiRedisClient } from '@umami/redis-client'; +import debug from 'debug'; +import { createClient, type RedisClientType } from 'redis'; + +const log = debug('umami:redis-client'); + +export const DELETED = '__DELETED__'; +export const DEFAULT_TTL = 3600; + +const logError = (err: unknown) => log(err); + +class UmamiRedisClient { + url: string; + client: RedisClientType; + isConnected: boolean; + + constructor(url: string) { + const client = createClient({ url }).on('error', logError); + + this.url = url; + this.client = client as RedisClientType; + this.isConnected = false; + } + + async connect() { + if (!this.isConnected) { + this.isConnected = true; + + await this.client.connect(); + + log('Redis connected'); + } + } + + async get(key: string) { + await this.connect(); + + const data = await this.client.get(key); + + try { + return JSON.parse(data as string); + } catch { + return null; + } + } + + async set(key: string, value: any, time?: number) { + await this.connect(); + + const ttl = time && time > 0 ? time : DEFAULT_TTL; + + return this.client.set(key, JSON.stringify(value), { EX: ttl }); + } + + async del(key: string) { + await this.connect(); + + return this.client.del(key); + } + + async incr(key: string) { + await this.connect(); + + return this.client.incr(key); + } + + async expire(key: string, seconds: number) { + await this.connect(); + + return this.client.expire(key, seconds); + } + + async rateLimit(key: string, limit: number, seconds: number): Promise { + await this.connect(); + + const res = await this.client.incr(key); + + if (res === 1) { + await this.client.expire(key, seconds); + } + + return res >= limit; + } + + async fetch(key: string, query: () => Promise, time?: number) { + const result = await this.get(key); + + if (result === DELETED) return null; + + if (!result && query) { + const data = await query(); + if (data) { + await this.set(key, data, time); + } + return data; + } + + return result; + } + + async remove(key: string, soft = false) { + return soft ? this.set(key, DELETED) : this.del(key); + } +} const REDIS = 'redis'; const enabled = !!process.env.REDIS_URL; function getClient() { - const redis = new UmamiRedisClient({ url: process.env.REDIS_URL }); + const redis = new UmamiRedisClient(process.env.REDIS_URL); if (process.env.NODE_ENV !== 'production') { globalThis[REDIS] = redis; @@ -13,6 +115,6 @@ function getClient() { return redis; } -const client = globalThis[REDIS] || getClient(); +const client: UmamiRedisClient = globalThis[REDIS] || getClient(); export default { client, enabled };