Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.7.5",
"@types/node-cache": "^4.1.3",
"@types/pg": "^8.20.0",
"@vitest/coverage-v8": "^4.1.2",
"gts": "^7.0.0",
Expand Down Expand Up @@ -71,6 +72,7 @@
"fastify-plugin": "^5.1.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
"node-cache": "^5.1.2",
"pg": "^8.20.0"
}
}
132 changes: 132 additions & 0 deletions src/plugin/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {FastifyInstance} from 'fastify';
import fp from 'fastify-plugin';
import Redis from 'ioredis';
import NodeCache from 'node-cache';

export interface ICache {
get<T = unknown>(key: string): Promise<T | null>;
set<T = unknown>(key: string, value: T, ttlSeconds?: number): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
clear(): Promise<void>;
raw: Redis | NodeCache;
}

export default fp(
async (fastify: FastifyInstance) => {
let cacheWrapper: ICache;
const cacheConfig = fastify.appConfig?.cache_db;

if (
cacheConfig &&
cacheConfig.engine === 'redis' &&
cacheConfig.connection &&
cacheConfig.connection.uri
) {
// Use Redis
const redis = new Redis(cacheConfig.connection.uri, {
connectTimeout: cacheConfig.timeout ?? 5000,
retryStrategy: times => Math.min(times * 50, 2000),
maxRetriesPerRequest: 3,
});

redis.on('error', err => {
fastify.log.error(`Redis connection error: ${err.message}`);
});

redis.on('connect', () => {
fastify.log.info('Redis connection established');
});

redis.on('ready', () => {
fastify.log.info('Redis client ready');
});

try {
await redis.ping();
fastify.log.info(
`Redis connection successful to ${cacheConfig.connection.uri}`,
);
} catch (err) {
fastify.log.error(`Failed to connect to Redis: ${err}`);
throw err;
}

cacheWrapper = {
async get<T>(key: string) {
const val = await redis.get(key);
if (!val) return null;
try {
return JSON.parse(val) as T;
} catch {
return val as unknown as T;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number) {
const strVal =
typeof value === 'string' ? value : JSON.stringify(value);
if (ttlSeconds) {
await redis.set(key, strVal, 'EX', ttlSeconds);
} else {
await redis.set(key, strVal);
}
},
async delete(key: string) {
await redis.del(key);
},
async has(key: string) {
const exists = await redis.exists(key);
return exists > 0;
},
async clear() {
await redis.flushdb();
},
raw: redis,
};

fastify.addHook('onClose', async () => {
fastify.log.info('Closing Redis connection...');
await redis.quit();
fastify.log.info('Redis connection closed.');
});
} else {
// Use node-cache
fastify.log.info('Using node-cache for caching');
const nodeCache = new NodeCache();

cacheWrapper = {
async get<T>(key: string) {
const val = nodeCache.get<T>(key);
return val === undefined ? null : val;
},
async set<T>(key: string, value: T, ttlSeconds?: number) {
if (ttlSeconds) {
nodeCache.set(key, value, ttlSeconds);
} else {
nodeCache.set(key, value);
}
},
async delete(key: string) {
nodeCache.del(key);
},
async has(key: string) {
return nodeCache.has(key);
},
async clear() {
nodeCache.flushAll();
},
raw: nodeCache,
};

fastify.addHook('onClose', async () => {
fastify.log.info('Closing node-cache...');
nodeCache.close();
});
}

fastify.decorate('cache', cacheWrapper);
},
{
name: 'cache-plugin',
},
);
42 changes: 36 additions & 6 deletions src/plugin/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import rateLimit, {FastifyRateLimitOptions} from '@fastify/rate-limit';
import {FastifyInstance} from 'fastify';
import fp from 'fastify-plugin';
import Redis from 'ioredis';

import {RateLimitConfig} from '@/interfaces/config';

import {parseDuration} from '@/utils/duration';

export interface RateLimitPluginOptions {
rateLimit: RateLimitConfig;
redis?: Redis;
}

export default fp(
async (fastify: FastifyInstance, opts: RateLimitPluginOptions) => {
const {rateLimit: config, redis} = opts;
const {rateLimit: config} = opts;

// If rate limiting is disabled, skip registration
if (!config.enabled) {
Expand All @@ -26,12 +24,44 @@ export default fp(
// Parse the time window from duration string to milliseconds
const windowMs = parseDuration(config.timeWindow);

// Prepare rate limit options
class CustomCacheStore {
constructor(private options: unknown) {}

incr(
key: string,
cb: (
err: Error | null,
result?: {current: number; ttl: number},
) => void,
) {
fastify.cache
.get<{current: number; expiresAt: number}>(key)
.then(val => {
const now = Date.now();
if (!val || val.expiresAt < now) {
val = {current: 1, expiresAt: now + windowMs};
} else {
val.current += 1;
}
fastify.cache
.set(key, val, Math.ceil((val.expiresAt - now) / 1000))
.then(() => {
cb(null, {current: val.current, ttl: val.expiresAt - now});
})
.catch(err => cb(err));
})
.catch(err => cb(err));
}

child() {
return this;
}
}

const rateLimitOpts: FastifyRateLimitOptions = {
max: config.max,
timeWindow: windowMs,
cache: config.useRedis && redis ? 1000 : 10000,
redis: config.useRedis && redis ? redis : undefined,
store: CustomCacheStore,
};

// Register the rate-limit plugin
Expand Down
53 changes: 0 additions & 53 deletions src/plugin/redis.ts

This file was deleted.

13 changes: 3 additions & 10 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import Fastify, {

import migrateDatabase from '@/migrator';
import authPlugin from '@/plugin/auth';
import cachePlugin from '@/plugin/cache';
import dbPlugin from '@/plugin/database';
import rateLimitPlugin from '@/plugin/rate-limit';
import redisPlugin from '@/plugin/redis';
import responsePlugin from '@/plugin/response';
import sspPlugin from '@/plugin/ssp';
import webhookPlugin from '@/plugin/webhook';
Expand Down Expand Up @@ -118,20 +118,13 @@ export async function startServer(
// config-driven DB
await app.register(dbPlugin, config.database);

// config-driven Redis cache (if configured)
if (config.cache_db) {
await app.register(redisPlugin, config.cache_db);
}
// config-driven cache (Redis or NodeCache)
await app.register(cachePlugin);

// config-driven rate limit
if (config.application.rateLimit) {
const redis =
config.cache_db && config.application.rateLimit.useRedis
? app.redis
: undefined;
await app.register(rateLimitPlugin, {
rateLimit: config.application.rateLimit,
redis,
});
}

Expand Down
4 changes: 1 addition & 3 deletions src/types/fastify.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import '@fastify/jwt';
import 'fastify';

import type Redis from 'ioredis';

import {DatabaseQuery, StructuredResponse} from '@/interfaces';

declare module '@fastify/jwt' {
Expand All @@ -27,7 +25,7 @@ declare module 'fastify' {
data: T,
raw_data?: R,
) => StructuredResponse<T, R>;
redis?: Redis;
cache: import('@/plugin/cache').ICache;
jwt: import('@fastify/jwt').JWT;
appConfig: AppConfig;
callWebhook: (
Expand Down
Loading
Loading