raahul-fastify-basic 1.0.0
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/README.md +80 -0
- package/bin/cli.js +129 -0
- package/package.json +30 -0
- package/templates/basic/.env +7 -0
- package/templates/basic/_gitignore +5 -0
- package/templates/basic/nodemon.json +6 -0
- package/templates/basic/package-lock.json +5103 -0
- package/templates/basic/package.json +56 -0
- package/templates/basic/scripts/setup-public.ts +21 -0
- package/templates/basic/src/api/index.ts +14 -0
- package/templates/basic/src/api/v1/index.ts +12 -0
- package/templates/basic/src/config/constants.ts +15 -0
- package/templates/basic/src/config/env.ts +32 -0
- package/templates/basic/src/core/fastify.ts +25 -0
- package/templates/basic/src/core/server.ts +55 -0
- package/templates/basic/src/core/socket.ts +62 -0
- package/templates/basic/src/index.ts +23 -0
- package/templates/basic/src/infra/db/mongodb/mongodb.service.ts +73 -0
- package/templates/basic/src/infra/redis/redis.service.ts +42 -0
- package/templates/basic/src/infra/redis/utils/keys.ts +11 -0
- package/templates/basic/src/infra/redis/utils/lock.ts +118 -0
- package/templates/basic/src/inx.ts +10 -0
- package/templates/basic/src/types/global.d.ts +16 -0
- package/templates/basic/src/utils/assert.ts +7 -0
- package/templates/basic/tsconfig.json +27 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "backend",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
8
|
+
"dev": "nodemon",
|
|
9
|
+
"start:tunnel": "node src/inx.ts",
|
|
10
|
+
"dev:tunnel": "node src/inx.ts",
|
|
11
|
+
"start": "node dist/index.js",
|
|
12
|
+
"build": "tsc && tsc-alias && npm run postbuild && npm run sentry:sourcemaps",
|
|
13
|
+
"postbuild": "node -e \"const fs = require('fs'); try { fs.cpSync('src/services/email/views', 'dist/services/email/views', { recursive: true }); } catch (e) {}\"",
|
|
14
|
+
"setup": "node scripts/setup-public.ts",
|
|
15
|
+
"postinstall": "node scripts/setup-public.ts"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"fastify",
|
|
19
|
+
"backend"
|
|
20
|
+
],
|
|
21
|
+
"author": "Rahul Mehta",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@fastify/cookie": "^11.0.2",
|
|
26
|
+
"@fastify/cors": "^11.2.0",
|
|
27
|
+
"@fastify/multipart": "^10.0.0",
|
|
28
|
+
"@fastify/static": "^9.1.3",
|
|
29
|
+
"@socket.io/redis-adapter": "^8.3.0",
|
|
30
|
+
"axios": "^1.16.1",
|
|
31
|
+
"bcrypt": "^6.0.0",
|
|
32
|
+
"dotenv": "^17.4.2",
|
|
33
|
+
"fastify": "^5.8.5",
|
|
34
|
+
"googleapis": "^172.0.0",
|
|
35
|
+
"jose": "^6.2.3",
|
|
36
|
+
"jsonwebtoken": "^9.0.3",
|
|
37
|
+
"mongoose": "^9.6.2",
|
|
38
|
+
"nodemailer": "^8.0.8",
|
|
39
|
+
"nodemailer-express-handlebars": "^7.0.0",
|
|
40
|
+
"redis": "^5.12.1",
|
|
41
|
+
"sharp": "^0.34.5",
|
|
42
|
+
"socket.io": "^4.8.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@ngrok/ngrok": "^1.7.0",
|
|
46
|
+
"@types/bcrypt": "^6.0.0",
|
|
47
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
48
|
+
"@types/node": "^25.9.1",
|
|
49
|
+
"@types/nodemailer": "^8.0.0",
|
|
50
|
+
"@types/nodemailer-express-handlebars": "^4.0.6",
|
|
51
|
+
"nodemon": "^3.1.14",
|
|
52
|
+
"tsc-alias": "^1.8.17",
|
|
53
|
+
"tsx": "^4.22.3",
|
|
54
|
+
"typescript": "^6.0.3"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
const _dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const projectRoot = path.resolve(_dirname, "..");
|
|
6
|
+
const requiredFolders = ["public/uploads"];
|
|
7
|
+
function createFolders() {
|
|
8
|
+
requiredFolders.forEach((folderName) => {
|
|
9
|
+
const folderPath = path.join(projectRoot, folderName);
|
|
10
|
+
if (!fs.existsSync(folderPath)) {
|
|
11
|
+
fs.mkdirSync(folderPath, { recursive: true });
|
|
12
|
+
console.log(`[script/setup-public] Folder created ${folderName}`);
|
|
13
|
+
} else {
|
|
14
|
+
console.log(
|
|
15
|
+
`[script/setup-public] Folder already exists ${folderName}, skipping and trying next`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
createFolders();
|
|
21
|
+
export { createFolders };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import apiV1Router from "./v1/index.js";
|
|
3
|
+
async function apiRouter(fastify: FastifyInstance, _options: object) {
|
|
4
|
+
fastify.get("/", (_req, reply) => {
|
|
5
|
+
return reply.status(200).send({
|
|
6
|
+
ok: true,
|
|
7
|
+
success: true,
|
|
8
|
+
message: "OK",
|
|
9
|
+
at: "backend/api",
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
fastify.register(apiV1Router, { prefix: "/v1" });
|
|
13
|
+
}
|
|
14
|
+
export default apiRouter;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FastifyInstance } from "fastify";
|
|
2
|
+
const apiV1Router = async (fastify: FastifyInstance, options: object) => {
|
|
3
|
+
fastify.get("/", (_request, reply) => {
|
|
4
|
+
return reply.status(200).send({
|
|
5
|
+
ok: true,
|
|
6
|
+
success: true,
|
|
7
|
+
message: "OK",
|
|
8
|
+
at: "backend/api/v1",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
export default apiV1Router;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FastifyCorsOptions } from "@fastify/cors";
|
|
2
|
+
import { ENV } from "./env.js";
|
|
3
|
+
export const CONSTANTS: Record<string, any> = {
|
|
4
|
+
CORS_OPTIONS: {
|
|
5
|
+
credentials: true,
|
|
6
|
+
origin: ENV.ALLOWED_ORIGINS.split(",").filter((origin) => origin),
|
|
7
|
+
allowedHeaders: [
|
|
8
|
+
"Content-Type",
|
|
9
|
+
"Authorization",
|
|
10
|
+
...(ENV.MODE === "dev" ? ["ngrok-skip-browser-warning", ""] : []),
|
|
11
|
+
],
|
|
12
|
+
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
13
|
+
} satisfies FastifyCorsOptions,
|
|
14
|
+
|
|
15
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assertValue } from "@/utils/assert.js";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
|
7
|
+
export const ENV = {
|
|
8
|
+
MODE: assertValue(process.env.MODE, "Missing environment variable : MODE"),
|
|
9
|
+
PORT: Number(
|
|
10
|
+
assertValue(process.env.PORT, "Missing environment Variable : PORT"),
|
|
11
|
+
),
|
|
12
|
+
ALLOWED_ORIGINS: assertValue(
|
|
13
|
+
process.env.ALLOWED_ORIGINS,
|
|
14
|
+
"Missing environment variable: ALLOWED_ORIGINS",
|
|
15
|
+
),
|
|
16
|
+
JWT_SECRET: assertValue(
|
|
17
|
+
process.env.JWT_SECRET,
|
|
18
|
+
"Missing environment variable: JWT_SECRET",
|
|
19
|
+
),
|
|
20
|
+
REDIS_URL: assertValue(
|
|
21
|
+
process.env.REDIS_URL,
|
|
22
|
+
"Missing environment variable: REDIS_URL",
|
|
23
|
+
),
|
|
24
|
+
MONGOS_CONNECTION_STR: assertValue(
|
|
25
|
+
process.env.MONGOS_CONNECTION_STR,
|
|
26
|
+
"Missing environment variable: MONGOS_CONNECTION_STR",
|
|
27
|
+
),
|
|
28
|
+
NGROK_AUTH_TOKEN: assertValue(
|
|
29
|
+
process.env.NGROK_AUTH_TOKEN,
|
|
30
|
+
"Missing environment variable: NGROK_AUTH_TOKEN",
|
|
31
|
+
),
|
|
32
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Fastify, { FastifyInstance } from "fastify";
|
|
2
|
+
import cors from "@fastify/cors";
|
|
3
|
+
import cookie from "@fastify/cookie";
|
|
4
|
+
import { Server as HttpServer } from "node:http";
|
|
5
|
+
import { CONSTANTS } from "@/config/constants.js";
|
|
6
|
+
class FastifyServer {
|
|
7
|
+
public readonly app: FastifyInstance;
|
|
8
|
+
public readonly server: HttpServer;
|
|
9
|
+
constructor() {
|
|
10
|
+
this.app = Fastify({
|
|
11
|
+
trustProxy: true,
|
|
12
|
+
logger: process.env.MODE === "dev",
|
|
13
|
+
});
|
|
14
|
+
this.server = this.app.server;
|
|
15
|
+
this.registerPlugins();
|
|
16
|
+
}
|
|
17
|
+
private async registerPlugins() {
|
|
18
|
+
await this.app.register(cors, CONSTANTS.CORS_OPTIONS);
|
|
19
|
+
await this.app.register(cookie);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const fastifyService = new FastifyServer();
|
|
23
|
+
export default fastifyService;
|
|
24
|
+
export const app = fastifyService.app;
|
|
25
|
+
export const server = fastifyService.server;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export const startServer = async () => {
|
|
3
|
+
const [
|
|
4
|
+
{ app },
|
|
5
|
+
{ default: MongoDBService },
|
|
6
|
+
{ default: fastifyStatic },
|
|
7
|
+
{ ENV },
|
|
8
|
+
apiRouter,
|
|
9
|
+
_io,
|
|
10
|
+
] = await Promise.all([
|
|
11
|
+
import("@/core/fastify.js"),
|
|
12
|
+
import("@/infra/db/mongodb/mongodb.service.js"),
|
|
13
|
+
import("@fastify/static"),
|
|
14
|
+
import("@/config/env.js"),
|
|
15
|
+
import("@/api/index.js"),
|
|
16
|
+
import("@/core/socket.js"),
|
|
17
|
+
]);
|
|
18
|
+
await app.register(fastifyStatic, {
|
|
19
|
+
root: path.resolve(process.cwd(), "public"),
|
|
20
|
+
prefix: "/",
|
|
21
|
+
wildcard: true,
|
|
22
|
+
});
|
|
23
|
+
app.get("/", (_req, reply) => {
|
|
24
|
+
return reply.type("text/html").send("OK");
|
|
25
|
+
});
|
|
26
|
+
app.register(apiRouter.default, {
|
|
27
|
+
prefix: "/api",
|
|
28
|
+
});
|
|
29
|
+
app.get("/health", async (_req, reply) => {
|
|
30
|
+
const healthy = await MongoDBService.checkDbHealth();
|
|
31
|
+
if (!healthy) {
|
|
32
|
+
return reply.status(500).send({
|
|
33
|
+
success: false,
|
|
34
|
+
status: 500,
|
|
35
|
+
message: "DB or API SERVER UNHEALTHY",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return reply.status(200).send("OK");
|
|
39
|
+
});
|
|
40
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
41
|
+
console.log("Global Error Caught", error);
|
|
42
|
+
return reply.status(500).send({
|
|
43
|
+
success: false,
|
|
44
|
+
status: 500,
|
|
45
|
+
message: "Internal Server Error",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
await app.listen({ port: Number(ENV.PORT), host: "0.0.0.0" });
|
|
50
|
+
console.log(`Server is running on port ${ENV.PORT}`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("❌ Server failed to start:", err);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { CONSTANTS } from "@/config/constants.js";
|
|
2
|
+
import { Server as HttpServer } from "node:http";
|
|
3
|
+
import { Server } from "socket.io";
|
|
4
|
+
import { createAdapter } from "@socket.io/redis-adapter";
|
|
5
|
+
import jwt from "jsonwebtoken";
|
|
6
|
+
import { server } from "./fastify.js";
|
|
7
|
+
import { ENV } from "@/config/env.js";
|
|
8
|
+
import redisService from "@/infra/redis/redis.service.js";
|
|
9
|
+
class SocketService {
|
|
10
|
+
public readonly io: Server;
|
|
11
|
+
constructor(httpServer: HttpServer = server) {
|
|
12
|
+
this.io = new Server(httpServer, {
|
|
13
|
+
cors: CONSTANTS.CORS_OPTIONS as any,
|
|
14
|
+
});
|
|
15
|
+
this.io.use((socket, next) => {
|
|
16
|
+
const isHandshake = socket.handshake.query.sid === undefined;
|
|
17
|
+
if (!isHandshake) {
|
|
18
|
+
return next();
|
|
19
|
+
}
|
|
20
|
+
const header = socket.handshake.headers["authorization"];
|
|
21
|
+
if (!header) {
|
|
22
|
+
return next(new Error("no token"));
|
|
23
|
+
}
|
|
24
|
+
if (!header.startsWith("Bearer ")) {
|
|
25
|
+
return next(new Error("invalid authentication, Please login again"));
|
|
26
|
+
}
|
|
27
|
+
const token = header.substring(7);
|
|
28
|
+
jwt.verify(token, ENV.JWT_SECRET, (err: any, decoded: any) => {
|
|
29
|
+
if (err) {
|
|
30
|
+
return next(new Error("invalid authentication, Please login again"));
|
|
31
|
+
}
|
|
32
|
+
const user = {
|
|
33
|
+
...decoded,
|
|
34
|
+
_id: decoded._id,
|
|
35
|
+
user_type: decoded.user_type || (decoded.name ? "admin" : "user"),
|
|
36
|
+
};
|
|
37
|
+
(socket.request as any).user = user;
|
|
38
|
+
return next();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
this.initializeAdapter().catch((err) => {
|
|
42
|
+
console.error(
|
|
43
|
+
"❌ Failed to initialize Redis adapter for Socket.IO:",
|
|
44
|
+
err,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
private async initializeAdapter(): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
await redisService.connect();
|
|
51
|
+
const subClient = redisService.client.duplicate();
|
|
52
|
+
await subClient.connect();
|
|
53
|
+
this.io.adapter(createAdapter(redisService.client, subClient));
|
|
54
|
+
console.log("✅ Socket.IO Redis adapter connected");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error("❌ Redis adapter connection failed for Socket.IO:", error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const socketService = new SocketService();
|
|
61
|
+
const io = socketService.io;
|
|
62
|
+
export { SocketService, socketService as default, io };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const [{ default: mongodb }, { startServer }, { default: redisLockService }] =
|
|
2
|
+
await Promise.all([
|
|
3
|
+
import("./infra/db/mongodb/mongodb.service.js"),
|
|
4
|
+
import("./core/server.js"),
|
|
5
|
+
import("./infra/redis/utils/lock.js"),
|
|
6
|
+
]);
|
|
7
|
+
mongodb.connectDb().then(async () => {
|
|
8
|
+
await startServer();
|
|
9
|
+
const key = "index-sync-service";
|
|
10
|
+
try {
|
|
11
|
+
const lock = await redisLockService.acquireLock(key);
|
|
12
|
+
if (!lock) {
|
|
13
|
+
console.log(
|
|
14
|
+
"[index] Indexes already being synced by another process, skipping for this PID",
|
|
15
|
+
process.pid,
|
|
16
|
+
);
|
|
17
|
+
return;
|
|
18
|
+
} else {
|
|
19
|
+
await mongodb.syncAllModelIndexes();
|
|
20
|
+
redisLockService.releaseLock(key, lock);
|
|
21
|
+
}
|
|
22
|
+
} catch (error) {}
|
|
23
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ENV } from "@/config/env.js";
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
let cached = global.__mongoose;
|
|
4
|
+
if (!cached) {
|
|
5
|
+
cached = global.__mongoose = { conn: null, promise: null };
|
|
6
|
+
}
|
|
7
|
+
class MongoDBService {
|
|
8
|
+
async connectDb() {
|
|
9
|
+
if (cached.conn) {
|
|
10
|
+
return Promise.resolve(cached.conn);
|
|
11
|
+
}
|
|
12
|
+
if (cached.promise) {
|
|
13
|
+
return cached.promise;
|
|
14
|
+
}
|
|
15
|
+
console.log("Connecting to MongoDB...");
|
|
16
|
+
cached.promise = mongoose
|
|
17
|
+
.connect(ENV.MONGOS_CONNECTION_STR, {
|
|
18
|
+
bufferCommands: false,
|
|
19
|
+
maxPoolSize: 95,
|
|
20
|
+
minPoolSize: 15,
|
|
21
|
+
serverSelectionTimeoutMS: 45000,
|
|
22
|
+
socketTimeoutMS: 45000,
|
|
23
|
+
connectTimeoutMS: 45000,
|
|
24
|
+
})
|
|
25
|
+
.then((mongooseInstance) => {
|
|
26
|
+
console.log("✅ MongoDB connection established");
|
|
27
|
+
cached.conn = mongooseInstance;
|
|
28
|
+
return mongooseInstance;
|
|
29
|
+
})
|
|
30
|
+
.catch((err: any) => {
|
|
31
|
+
cached.promise = null;
|
|
32
|
+
console.log("❌ MongoDB connection error:", err);
|
|
33
|
+
throw err;
|
|
34
|
+
});
|
|
35
|
+
return cached.promise;
|
|
36
|
+
}
|
|
37
|
+
async syncAllModelIndexes() {
|
|
38
|
+
const modelNames = mongoose.modelNames();
|
|
39
|
+
const syncs = [];
|
|
40
|
+
for (const modelName of modelNames) {
|
|
41
|
+
try {
|
|
42
|
+
const model = mongoose.model(modelName);
|
|
43
|
+
await model.syncIndexes();
|
|
44
|
+
syncs.push(modelName);
|
|
45
|
+
} catch (err: any) {
|
|
46
|
+
console.log(
|
|
47
|
+
`❌ Failed to sync indexes for ${modelName}:`,
|
|
48
|
+
err?.message,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
console.log(`✅ Indexes synced for models: ${syncs?.join("|")}`);
|
|
53
|
+
}
|
|
54
|
+
async checkDbHealth() {
|
|
55
|
+
try {
|
|
56
|
+
if (mongoose.connection.readyState !== 1) {
|
|
57
|
+
return false;
|
|
58
|
+
} else {
|
|
59
|
+
const pingResult: mongoose.mongo.BSON.Document | undefined =
|
|
60
|
+
await mongoose?.connection?.db?.admin?.()?.ping();
|
|
61
|
+
if (pingResult && pingResult.ok === 1) {
|
|
62
|
+
return true;
|
|
63
|
+
} else {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.log(`Error while checking the health of data base.`, error);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export default new MongoDBService();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ENV } from "@/config/env.js";
|
|
2
|
+
import { createClient, RedisClientType } from "redis";
|
|
3
|
+
class RedisService {
|
|
4
|
+
public readonly client: RedisClientType;
|
|
5
|
+
constructor() {
|
|
6
|
+
this.client = createClient({ url: ENV.REDIS_URL }) as RedisClientType;
|
|
7
|
+
this.client.on("error", (err) => {
|
|
8
|
+
console.error("❌ Redis Client Error:", err);
|
|
9
|
+
});
|
|
10
|
+
this.client.on("connect", () => {
|
|
11
|
+
console.log("Redis client connecting...");
|
|
12
|
+
});
|
|
13
|
+
this.client.on("ready", () => {
|
|
14
|
+
console.log("✅ Redis connection established and ready");
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async connect(): Promise<void> {
|
|
18
|
+
if (!this.client.isOpen) {
|
|
19
|
+
console.log("Connecting to Redis...");
|
|
20
|
+
await this.client.connect();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async shutdown(): Promise<void> {
|
|
24
|
+
console.log("Shutting down Redis clients...");
|
|
25
|
+
try {
|
|
26
|
+
if (this.client.isOpen) {
|
|
27
|
+
await this.client.del("global_active_sockets");
|
|
28
|
+
await this.client.del("active_sessions");
|
|
29
|
+
await this.client.quit();
|
|
30
|
+
console.log("✅ Redis client shut down successfully");
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error("❌ Error closing Redis clients:", err);
|
|
34
|
+
} finally {
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const redisService = new RedisService();
|
|
40
|
+
const client = redisService.client;
|
|
41
|
+
const shutdown = () => redisService.shutdown();
|
|
42
|
+
export { RedisService, redisService as default, client, shutdown };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function getTodayStr(): string {
|
|
2
|
+
const today = new Date();
|
|
3
|
+
const yyyy = today.getFullYear();
|
|
4
|
+
const mm = String(today.getMonth() + 1).padStart(2, "0");
|
|
5
|
+
const dd = String(today.getDate()).padStart(2, "0");
|
|
6
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
7
|
+
}
|
|
8
|
+
const REDIS_KEYS: Record<string, string | Function> = {
|
|
9
|
+
todaySessions: () => `rc-users-sessions:today:${getTodayStr()}`,
|
|
10
|
+
};
|
|
11
|
+
export default REDIS_KEYS;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { client } from "../redis.service.js";
|
|
2
|
+
import type { RedisClientType } from "redis";
|
|
3
|
+
class RedisLockService {
|
|
4
|
+
private readonly client: RedisClientType;
|
|
5
|
+
constructor(redisClient: RedisClientType = client) {
|
|
6
|
+
this.client = redisClient;
|
|
7
|
+
}
|
|
8
|
+
async acquireLock(key: string, ttl: number = 300000): Promise<string | null> {
|
|
9
|
+
if (!this.client.isOpen) return null;
|
|
10
|
+
const lockKey = `lock:${key}`;
|
|
11
|
+
const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
12
|
+
try {
|
|
13
|
+
const result = await this.client.set(lockKey, lockValue, {
|
|
14
|
+
NX: true,
|
|
15
|
+
PX: ttl,
|
|
16
|
+
});
|
|
17
|
+
return result === "OK" ? lockValue : null;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error(`[RedisLock] acquireLock failed for key "${key}":`, error);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async releaseLock(key: string, lockValue: string | number): Promise<number> {
|
|
24
|
+
if (!this.client.isOpen) return 0;
|
|
25
|
+
const lockKey = `lock:${key}`;
|
|
26
|
+
try {
|
|
27
|
+
const current = await this.client.get(lockKey);
|
|
28
|
+
if (current === String(lockValue)) {
|
|
29
|
+
return await this.client.del(lockKey);
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`[RedisLock] releaseLock failed for key "${key}":`, error);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async withLock<T>(
|
|
38
|
+
key: string,
|
|
39
|
+
fn: () => Promise<T>,
|
|
40
|
+
options: {
|
|
41
|
+
ttlMs?: number;
|
|
42
|
+
retries?: number;
|
|
43
|
+
retryDelayMs?: number;
|
|
44
|
+
fallback?: "skip" | "run" | "throw";
|
|
45
|
+
} = {},
|
|
46
|
+
): Promise<T | undefined> {
|
|
47
|
+
const {
|
|
48
|
+
ttlMs = 30000,
|
|
49
|
+
retries = 3,
|
|
50
|
+
retryDelayMs = 150,
|
|
51
|
+
fallback = "run",
|
|
52
|
+
} = options;
|
|
53
|
+
let lockValue: string | null = null;
|
|
54
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
55
|
+
lockValue = await this.acquireLock(key, ttlMs);
|
|
56
|
+
if (lockValue) break;
|
|
57
|
+
if (attempt < retries) {
|
|
58
|
+
await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!lockValue) {
|
|
62
|
+
console.warn(
|
|
63
|
+
`[RedisLock] Could not acquire lock "${key}" after ${retries} retries.`,
|
|
64
|
+
);
|
|
65
|
+
if (fallback === "skip") return undefined;
|
|
66
|
+
if (fallback === "throw")
|
|
67
|
+
throw new Error(`Could not acquire lock: ${key}`);
|
|
68
|
+
return fn()
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return await fn();
|
|
72
|
+
} finally {
|
|
73
|
+
await this.releaseLock(key, lockValue);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async getWithLockAndCache<T>(
|
|
77
|
+
cacheKey: string,
|
|
78
|
+
fetchDb: () => Promise<T>,
|
|
79
|
+
ttlSeconds: number = 3600,
|
|
80
|
+
): Promise<T> {
|
|
81
|
+
if (!this.client.isOpen) return fetchDb();
|
|
82
|
+
try {
|
|
83
|
+
const cached = await this.client.get(cacheKey);
|
|
84
|
+
if (cached) return JSON.parse(cached) as T;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.warn(`[RedisLock] Cache read failed for "${cacheKey}":`, err);
|
|
87
|
+
}
|
|
88
|
+
const lockValue = await this.acquireLock(`cache_fill:${cacheKey}`, 10000);
|
|
89
|
+
if (lockValue) {
|
|
90
|
+
try {
|
|
91
|
+
const cached = await this.client.get(cacheKey);
|
|
92
|
+
if (cached) return JSON.parse(cached) as T;
|
|
93
|
+
|
|
94
|
+
const result = await fetchDb();
|
|
95
|
+
await this.client.set(cacheKey, JSON.stringify(result), {
|
|
96
|
+
EX: ttlSeconds,
|
|
97
|
+
});
|
|
98
|
+
return result;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn(`[RedisLock] Cache fill failed for "${cacheKey}":`, err);
|
|
101
|
+
return fetchDb();
|
|
102
|
+
} finally {
|
|
103
|
+
await this.releaseLock(`cache_fill:${cacheKey}`, lockValue);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (let i = 0; i < 5; i++) {
|
|
107
|
+
await new Promise((r) => setTimeout(r, 100 * (i + 1)));
|
|
108
|
+
try {
|
|
109
|
+
const cached = await this.client.get(cacheKey);
|
|
110
|
+
if (cached) return JSON.parse(cached) as T;
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return fetchDb();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const redisLockService = new RedisLockService();
|
|
118
|
+
export { RedisLockService, redisLockService as default };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as ngrok from "@ngrok/ngrok";
|
|
2
|
+
import "@/index.js";
|
|
3
|
+
import { ENV } from "./config/env.js";
|
|
4
|
+
(async () => {
|
|
5
|
+
const listener = await ngrok.connect({
|
|
6
|
+
addr: ENV.PORT,
|
|
7
|
+
authtoken: ENV.NGROK_AUTH_TOKEN,
|
|
8
|
+
});
|
|
9
|
+
console.log("Backend Ingress (NGROK) URL:", listener.url());
|
|
10
|
+
})();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FastifyReply } from "fastify";
|
|
2
|
+
declare global {
|
|
3
|
+
var jarvis: (...args: any[]) => void;
|
|
4
|
+
var errJarvis: (...args: any[]) => void;
|
|
5
|
+
var handleError: (
|
|
6
|
+
err: any,
|
|
7
|
+
res: FastifyReply,
|
|
8
|
+
status?: number,
|
|
9
|
+
message?: string,
|
|
10
|
+
) => void;
|
|
11
|
+
var __mongoose: {
|
|
12
|
+
conn: typeof import("mongoose") | null;
|
|
13
|
+
promise: Promise<typeof import("mongoose")> | null;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"paths": {
|
|
7
|
+
"@/*": [
|
|
8
|
+
"./src/*"
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"rootDir": "./src",
|
|
13
|
+
"strict": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"sourceMap": true,
|
|
18
|
+
"inlineSources": true,
|
|
19
|
+
"sourceRoot": "/",
|
|
20
|
+
"types": [
|
|
21
|
+
"node"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"include": [
|
|
25
|
+
"src/**/*",
|
|
26
|
+
]
|
|
27
|
+
}
|