node-ts-app-starter 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 +97 -0
- package/bin/cli.js +62 -0
- package/package.json +22 -0
- package/template/.env.example +12 -0
- package/template/eslint.config.txt +14 -0
- package/template/gitignore.txt +141 -0
- package/template/package.json +71 -0
- package/template/prettierrc.txt +5 -0
- package/template/src/app.ts +85 -0
- package/template/src/config/env.config.ts +42 -0
- package/template/src/config/logger.config.ts +14 -0
- package/template/src/config/queue.config.ts +18 -0
- package/template/src/config/redis.config.ts +21 -0
- package/template/src/config/sendgrid.config.ts +8 -0
- package/template/src/config/supabase.config.ts +13 -0
- package/template/src/mail/mail.service.ts +42 -0
- package/template/src/middleware/apiError.ts +18 -0
- package/template/src/middleware/globalErrorHandler.ts +60 -0
- package/template/src/middleware/pino.ts +20 -0
- package/template/src/routes/index.ts +5 -0
- package/template/src/types/types.ts +9 -0
- package/template/src/utils/helper.ts +30 -0
- package/template/tsconfig.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# node-starter-project
|
|
2
|
+
|
|
3
|
+
A high-performance, production-ready Node.js + TypeScript boilerplate generator. This CLI scaffolds a complete backend architecture featuring Supabase, Redis, BullMQ, and Express with best-practice security and validation middleware.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
You don't need to install this package globally. Simply run the following command to scaffold a new project:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx node-ts-starter <your-project-name>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Next Steps:
|
|
14
|
+
|
|
15
|
+
1. ```cd <your-project-name>```
|
|
16
|
+
|
|
17
|
+
2. Configure your .env file (see .env.example).
|
|
18
|
+
|
|
19
|
+
3. Run the command ```npm run dev``` to start the server
|
|
20
|
+
|
|
21
|
+
## Supabase Type Generation
|
|
22
|
+
|
|
23
|
+
To get full IntelliSense for your database tables, you can generate types directly from your Supabase project.
|
|
24
|
+
|
|
25
|
+
1. Find your Project ID in your Supabase Dashboard (Settings > General).
|
|
26
|
+
|
|
27
|
+
2. Open your ```package.json```.
|
|
28
|
+
|
|
29
|
+
3. Update the ```gen-types``` script by replacing the placeholder ```YOUR_PROJECT_ID``` with your own:
|
|
30
|
+
```"gen-types": "npx supabase gen types typescript --project-id YOUR_PROJECT_ID --schema public > src/types/database.types.ts"```
|
|
31
|
+
|
|
32
|
+
4. Run the script ```npm run gen-types``` to generate your supabase typescript definitions directly from your supabase schema.
|
|
33
|
+
|
|
34
|
+
## Logging with Pino
|
|
35
|
+
|
|
36
|
+
The starter uses Pino for logging. In development, logs are pretty-printed for readability. In production, they are emitted as high-speed JSON for compatibility with log aggregators (like Datadog or ELK).
|
|
37
|
+
|
|
38
|
+
* **Development**: Logs are readable and color-coded.
|
|
39
|
+
|
|
40
|
+
* **Production**: Logs are optimized for minimal CPU overhead.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
The generated project comes pre-configured with a modular architecture:
|
|
45
|
+
|
|
46
|
+
* Framework: Express.js (v5+) with TypeScript.
|
|
47
|
+
|
|
48
|
+
* Database & Auth: Integrated Supabase client.
|
|
49
|
+
|
|
50
|
+
* Caching & Queues: Redis integration with BullMQ for universal background job processing.
|
|
51
|
+
|
|
52
|
+
* Security: Helmet, CORS, Express Rate Limit, and XSS Sanitizer.
|
|
53
|
+
|
|
54
|
+
* Validation: Ajv for high-speed JSON Schema validation.
|
|
55
|
+
|
|
56
|
+
* Email: SendGrid service wrapper with support for background sending via BullMQ.
|
|
57
|
+
|
|
58
|
+
* Middleware: Custom Global Error Handler, apiError utility classes and pino middleware to add request ID to every request for traceability.
|
|
59
|
+
|
|
60
|
+
* Logging: Every request is logged using pino with the respective request Id attached for logging and monitoring purposes
|
|
61
|
+
|
|
62
|
+
* Code Quality: ESLint and Prettier pre-configured for TypeScript.
|
|
63
|
+
|
|
64
|
+
## Folder Structure
|
|
65
|
+
|
|
66
|
+
src/
|
|
67
|
+
├── config/ # Service configurations (Supabase, Redis, Mail, Queues, Logger)
|
|
68
|
+
├── middleware/ # Global error handling, validation, logging
|
|
69
|
+
├── utils/ # reusable helper functions
|
|
70
|
+
├── routes/ # Express route definitions
|
|
71
|
+
├── mail/ # Sendgrid mailer service logic
|
|
72
|
+
├── types/ # TypeScript interfaces and enums
|
|
73
|
+
└── app.ts # Application entry point
|
|
74
|
+
|
|
75
|
+
## Available Scripts
|
|
76
|
+
|
|
77
|
+
Once the project is generated, you can use the following commands:
|
|
78
|
+
|
|
79
|
+
* ```npm run dev```: Start the development server with ```nodemon``` and ```ts-node```.
|
|
80
|
+
|
|
81
|
+
* ```npm run build```: Compile TypeScript to JavaScript in the ```dist/``` folder.
|
|
82
|
+
|
|
83
|
+
* ```npm run gen-types```: Pull TypeScript types from Supabase.
|
|
84
|
+
|
|
85
|
+
* ```npm start```: Run the compiled production build.
|
|
86
|
+
|
|
87
|
+
* ```npm run lint```: Check for code style and logic issues.
|
|
88
|
+
|
|
89
|
+
* ```npm run format```: Automatically fix code formatting with Prettier.
|
|
90
|
+
|
|
91
|
+
## Author
|
|
92
|
+
|
|
93
|
+
**George Kwabena Asiedu**
|
|
94
|
+
|
|
95
|
+
* GitHub: george-asiedu
|
|
96
|
+
|
|
97
|
+
* Email: <asiedug41@gmail.com>
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const shell = require('shelljs');
|
|
5
|
+
|
|
6
|
+
// Capture the project name from the argument (default to 'my-node-app')
|
|
7
|
+
const projectName = process.argv[2] || 'my-node-app';
|
|
8
|
+
const destination = path.join(process.cwd(), projectName);
|
|
9
|
+
const templatePath = path.join(__dirname, '../template');
|
|
10
|
+
|
|
11
|
+
// Prevent overwriting existing folders
|
|
12
|
+
if (fs.existsSync(destination)) {
|
|
13
|
+
console.error(`Error: Folder "${projectName}" already exists.`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`Creating a new project in: ${destination}...`);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
21
|
+
fs.cpSync(templatePath, destination, { recursive: true });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error('Error copying template files:', err);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const filesToRename = {
|
|
28
|
+
'gitignore.txt': '.gitignore',
|
|
29
|
+
'prettierrc.txt': '.prettierrc',
|
|
30
|
+
'eslint.config.txt': 'eslint.config.mjs'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
Object.entries(filesToRename).forEach(([oldName, newName]) => {
|
|
34
|
+
const oldPath = path.join(destination, oldName);
|
|
35
|
+
const newPath = path.join(destination, newName);
|
|
36
|
+
|
|
37
|
+
if (fs.existsSync(oldPath)) {
|
|
38
|
+
fs.renameSync(oldPath, newPath);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Update the package.json inside the NEW folder with the project name
|
|
43
|
+
const pkgPath = path.join(destination, 'package.json');
|
|
44
|
+
if (fs.existsSync(pkgPath)) {
|
|
45
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
46
|
+
pkg.name = projectName; // Change "starter-project" to whatever the user typed
|
|
47
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log('Installing dependencies, this may take a moment...');
|
|
51
|
+
|
|
52
|
+
shell.cd(destination);
|
|
53
|
+
if (shell.exec('npm install').code !== 0) {
|
|
54
|
+
console.error('Error installing dependencies');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
console.log(`
|
|
58
|
+
Success! Created ${projectName} at ${destination}
|
|
59
|
+
To get started:
|
|
60
|
+
cd ${projectName}
|
|
61
|
+
npm run dev
|
|
62
|
+
`);
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-ts-app-starter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to scaffold a Node.js + TypeScript project with Supabase, Redis, and Express",
|
|
5
|
+
"main": "bin/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-node-app": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"template"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test-local": "npm link",
|
|
15
|
+
"publish-project": "npm publish --access public"
|
|
16
|
+
},
|
|
17
|
+
"author": "george-asiedu <asiedug41@gmail.com>",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"shelljs": "^0.10.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
PORT=your-port
|
|
2
|
+
ENV=development
|
|
3
|
+
SUPABASE_URL=your-supabase-url
|
|
4
|
+
SUPABASE_KEY=your-supabase-key
|
|
5
|
+
SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
|
|
6
|
+
REDIS_HOST=your-redis-host
|
|
7
|
+
REDIS_PORT=your-redis-port
|
|
8
|
+
REDIS_USERNAME=default
|
|
9
|
+
REDIS_PASSWORD=your-redis-password
|
|
10
|
+
SENDGRID_API_KEY=your-sendgrid-api-key
|
|
11
|
+
SENDER_EMAIL=your-sender-email
|
|
12
|
+
CLIENT_URL=your-client-url
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
import tseslint from "typescript-eslint";
|
|
4
|
+
import { defineConfig } from "eslint/config";
|
|
5
|
+
import prettierPlugin from "eslint-plugin-prettier";
|
|
6
|
+
|
|
7
|
+
export default defineConfig([
|
|
8
|
+
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js, "@typescript-eslint": tseslint, prettier: prettierPlugin }, extends: ["js/recommended", "eslint:recommended",
|
|
9
|
+
"plugin:@typescript-eslint/recommended",
|
|
10
|
+
"plugin:prettier/recommended",], languageOptions: { globals: globals.node }, rules: {
|
|
11
|
+
"prettier/prettier": "error",
|
|
12
|
+
}, },
|
|
13
|
+
tseslint.configs.recommended,
|
|
14
|
+
]);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
lerna-debug.log*
|
|
8
|
+
|
|
9
|
+
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
10
|
+
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
11
|
+
|
|
12
|
+
# Runtime data
|
|
13
|
+
pids
|
|
14
|
+
*.pid
|
|
15
|
+
*.seed
|
|
16
|
+
*.pid.lock
|
|
17
|
+
|
|
18
|
+
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
19
|
+
lib-cov
|
|
20
|
+
|
|
21
|
+
# Coverage directory used by tools like istanbul
|
|
22
|
+
coverage
|
|
23
|
+
*.lcov
|
|
24
|
+
|
|
25
|
+
# nyc test coverage
|
|
26
|
+
.nyc_output
|
|
27
|
+
|
|
28
|
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
29
|
+
.grunt
|
|
30
|
+
|
|
31
|
+
# Bower dependency directory (https://bower.io/)
|
|
32
|
+
bower_components
|
|
33
|
+
|
|
34
|
+
# node-waf configuration
|
|
35
|
+
.lock-wscript
|
|
36
|
+
|
|
37
|
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
38
|
+
build/Release
|
|
39
|
+
|
|
40
|
+
# Dependency directories
|
|
41
|
+
node_modules/
|
|
42
|
+
jspm_packages/
|
|
43
|
+
|
|
44
|
+
# Snowpack dependency directory (https://snowpack.dev/)
|
|
45
|
+
web_modules/
|
|
46
|
+
|
|
47
|
+
# TypeScript cache
|
|
48
|
+
*.tsbuildinfo
|
|
49
|
+
|
|
50
|
+
# Optional npm cache directory
|
|
51
|
+
.npm
|
|
52
|
+
|
|
53
|
+
# Optional eslint cache
|
|
54
|
+
.eslintcache
|
|
55
|
+
|
|
56
|
+
# Optional stylelint cache
|
|
57
|
+
.stylelintcache
|
|
58
|
+
|
|
59
|
+
# Optional REPL history
|
|
60
|
+
.node_repl_history
|
|
61
|
+
|
|
62
|
+
# Output of 'npm pack'
|
|
63
|
+
*.tgz
|
|
64
|
+
|
|
65
|
+
# Yarn Integrity file
|
|
66
|
+
.yarn-integrity
|
|
67
|
+
|
|
68
|
+
# dotenv environment variable files
|
|
69
|
+
.env
|
|
70
|
+
.env.*
|
|
71
|
+
!.env.example
|
|
72
|
+
|
|
73
|
+
# parcel-bundler cache (https://parceljs.org/)
|
|
74
|
+
.cache
|
|
75
|
+
.parcel-cache
|
|
76
|
+
|
|
77
|
+
# Next.js build output
|
|
78
|
+
.next
|
|
79
|
+
out
|
|
80
|
+
|
|
81
|
+
# Nuxt.js build / generate output
|
|
82
|
+
.nuxt
|
|
83
|
+
dist
|
|
84
|
+
|
|
85
|
+
# Gatsby files
|
|
86
|
+
.cache/
|
|
87
|
+
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
88
|
+
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
89
|
+
# public
|
|
90
|
+
|
|
91
|
+
# vuepress build output
|
|
92
|
+
.vuepress/dist
|
|
93
|
+
|
|
94
|
+
# vuepress v2.x temp and cache directory
|
|
95
|
+
.temp
|
|
96
|
+
.cache
|
|
97
|
+
|
|
98
|
+
# Sveltekit cache directory
|
|
99
|
+
.svelte-kit/
|
|
100
|
+
|
|
101
|
+
# vitepress build output
|
|
102
|
+
**/.vitepress/dist
|
|
103
|
+
|
|
104
|
+
# vitepress cache directory
|
|
105
|
+
**/.vitepress/cache
|
|
106
|
+
|
|
107
|
+
# Docusaurus cache and generated files
|
|
108
|
+
.docusaurus
|
|
109
|
+
|
|
110
|
+
# Serverless directories
|
|
111
|
+
.serverless/
|
|
112
|
+
|
|
113
|
+
# FuseBox cache
|
|
114
|
+
.fusebox/
|
|
115
|
+
|
|
116
|
+
# DynamoDB Local files
|
|
117
|
+
.dynamodb/
|
|
118
|
+
|
|
119
|
+
# Firebase cache directory
|
|
120
|
+
.firebase/
|
|
121
|
+
|
|
122
|
+
# TernJS port file
|
|
123
|
+
.tern-port
|
|
124
|
+
|
|
125
|
+
# Stores VSCode versions used for testing VSCode extensions
|
|
126
|
+
.vscode-test
|
|
127
|
+
|
|
128
|
+
# yarn v3
|
|
129
|
+
.pnp.*
|
|
130
|
+
.yarn/*
|
|
131
|
+
!.yarn/patches
|
|
132
|
+
!.yarn/plugins
|
|
133
|
+
!.yarn/releases
|
|
134
|
+
!.yarn/sdks
|
|
135
|
+
!.yarn/versions
|
|
136
|
+
|
|
137
|
+
# Vite logs files
|
|
138
|
+
vite.config.js.timestamp-*
|
|
139
|
+
vite.config.ts.timestamp-*
|
|
140
|
+
.vscode/
|
|
141
|
+
.idea/
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "starter-project",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A high-performance, production-ready Node.js + TypeScript boilerplate generator. This CLI scaffolds a complete backend architecture featuring Supabase, Redis, BullMQ, and Express with best-practice security and validation middleware.",
|
|
5
|
+
"main": "app.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build:deploy": "npm install && npm build",
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"gen-types": "npx supabase gen types typescript --project-id YOUR_PROJECT_ID --schema public > src/types/database.types.ts",
|
|
10
|
+
"dev": "tsx watch src/app.ts",
|
|
11
|
+
"start": "node dist/app.js",
|
|
12
|
+
"lint": "eslint . --ext .ts",
|
|
13
|
+
"lint:fix": "eslint . --ext .ts --fix",
|
|
14
|
+
"format": "prettier --write ."
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"node",
|
|
18
|
+
"typescript",
|
|
19
|
+
"starter",
|
|
20
|
+
"utils",
|
|
21
|
+
"template"
|
|
22
|
+
],
|
|
23
|
+
"author": "george-asiedu <asiedug41@gmail.com>",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@sendgrid/mail": "^8.1.6",
|
|
28
|
+
"@supabase/supabase-js": "^2.97.0",
|
|
29
|
+
"@types/multer": "^2.0.0",
|
|
30
|
+
"ajv": "^8.18.0",
|
|
31
|
+
"ajv-errors": "^3.0.0",
|
|
32
|
+
"ajv-formats": "^3.0.1",
|
|
33
|
+
"bullmq": "^5.69.3",
|
|
34
|
+
"compression": "^1.8.1",
|
|
35
|
+
"cookie-parser": "^1.4.7",
|
|
36
|
+
"cors": "^2.8.6",
|
|
37
|
+
"dotenv": "^17.3.1",
|
|
38
|
+
"express": "^5.2.1",
|
|
39
|
+
"express-rate-limit": "^8.2.1",
|
|
40
|
+
"express-xss-sanitizer": "^2.0.1",
|
|
41
|
+
"helmet": "^8.1.0",
|
|
42
|
+
"morgan": "^1.10.1",
|
|
43
|
+
"multer": "^2.0.2",
|
|
44
|
+
"pino": "^10.3.1",
|
|
45
|
+
"pino-http": "^11.0.0",
|
|
46
|
+
"redis": "^5.11.0",
|
|
47
|
+
"uuid": "^13.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^10.0.1",
|
|
51
|
+
"@types/compression": "^1.8.1",
|
|
52
|
+
"@types/cookie-parser": "^1.4.10",
|
|
53
|
+
"@types/cors": "^2.8.19",
|
|
54
|
+
"@types/express": "^5.0.6",
|
|
55
|
+
"@types/morgan": "^1.9.10",
|
|
56
|
+
"@types/node": "^25.3.0",
|
|
57
|
+
"@types/sendgrid": "^2.0.31",
|
|
58
|
+
"@types/uuid": "^10.0.0",
|
|
59
|
+
"eslint": "^10.0.0",
|
|
60
|
+
"eslint-config-prettier": "^10.1.8",
|
|
61
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
62
|
+
"globals": "^17.3.0",
|
|
63
|
+
"jiti": "^2.6.1",
|
|
64
|
+
"nodemon": "^3.1.11",
|
|
65
|
+
"prettier": "^3.8.1",
|
|
66
|
+
"ts-node": "^10.9.2",
|
|
67
|
+
"tsx": "^4.21.0",
|
|
68
|
+
"typescript": "^5.9.3",
|
|
69
|
+
"typescript-eslint": "^8.56.0"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import express, { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import helmet from 'helmet';
|
|
4
|
+
import morgan from 'morgan';
|
|
5
|
+
import compression from 'compression';
|
|
6
|
+
import rateLimit from 'express-rate-limit';
|
|
7
|
+
import cookieParser from 'cookie-parser';
|
|
8
|
+
import { pinoMiddleware } from './middleware/pino.js';
|
|
9
|
+
import { env } from './config/env.config.js';
|
|
10
|
+
import { redisClient } from './config/redis.config.js';
|
|
11
|
+
import { supabase } from './config/supabase.config.js';
|
|
12
|
+
import { globalErrorHandler } from './middleware/globalErrorHandler.js';
|
|
13
|
+
import routes from './routes/index.js';
|
|
14
|
+
import logger from './config/logger.config.js';
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(pinoMiddleware);
|
|
18
|
+
|
|
19
|
+
app.use(morgan('dev'));
|
|
20
|
+
app.use(
|
|
21
|
+
helmet({
|
|
22
|
+
contentSecurityPolicy: false,
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
app.use(cors());
|
|
26
|
+
|
|
27
|
+
const limiter = rateLimit({
|
|
28
|
+
windowMs: 15 * 60 * 1000,
|
|
29
|
+
limit: 100,
|
|
30
|
+
message: "Too many requests from this IP, please try again later.",
|
|
31
|
+
});
|
|
32
|
+
app.use("/api", limiter);
|
|
33
|
+
|
|
34
|
+
app.use(compression());
|
|
35
|
+
app.use(cookieParser());
|
|
36
|
+
app.use(express.json({ limit: "10mb" }));
|
|
37
|
+
app.use(express.urlencoded({ extended: true }));
|
|
38
|
+
|
|
39
|
+
app.get('/', (_req: Request, res: Response) => {
|
|
40
|
+
res.send('Server is running!');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.get("/health", async (_req: Request, res: Response) => {
|
|
44
|
+
const healthStatus: any = {
|
|
45
|
+
status: "ok",
|
|
46
|
+
uptime: process.uptime(),
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
services: {
|
|
49
|
+
server: "healthy",
|
|
50
|
+
redis: "unknown",
|
|
51
|
+
supabase: "unknown",
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const redisPing = await redisClient.ping();
|
|
57
|
+
healthStatus.services.redis = redisPing === 'PONG' ? 'healthy' : 'unhealthy';
|
|
58
|
+
|
|
59
|
+
const { error } = await supabase.from("profiles").select("id").limit(1);
|
|
60
|
+
healthStatus.services.supabase = error ? 'unhealthy' : 'healthy';
|
|
61
|
+
|
|
62
|
+
} catch (error) {
|
|
63
|
+
healthStatus.status = "error";
|
|
64
|
+
logger.error({ err: error as Error }, 'Health check failed');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isHealthy = healthStatus.services.redis === 'healthy' &&
|
|
68
|
+
healthStatus.services.supabase === 'healthy';
|
|
69
|
+
|
|
70
|
+
res.status(isHealthy ? 200 : 503).json(healthStatus);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.use("/api", routes);
|
|
74
|
+
|
|
75
|
+
app.use((err: Error, req: Request, res: Response, next: NextFunction) =>
|
|
76
|
+
globalErrorHandler(err, req, res, next),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const port = env.port;
|
|
80
|
+
if (!port)
|
|
81
|
+
throw new Error("Port number is not defined in environment variables");
|
|
82
|
+
|
|
83
|
+
app.listen(port, () => {
|
|
84
|
+
logger.log(`Server is running on port ${port}`);
|
|
85
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const envFilePath = path.resolve(process.cwd(), `.env`);
|
|
5
|
+
|
|
6
|
+
dotenv.config({ path: envFilePath });
|
|
7
|
+
const requiredVars = [
|
|
8
|
+
'PORT',
|
|
9
|
+
'ENV',
|
|
10
|
+
'SUPABASE_URL',
|
|
11
|
+
'SUPABASE_KEY',
|
|
12
|
+
'SUPABASE_SERVICE_ROLE_KEY',
|
|
13
|
+
'SENDGRID_API_KEY',
|
|
14
|
+
'SENDER_EMAIL',
|
|
15
|
+
'REDIS_HOST',
|
|
16
|
+
'REDIS_PORT',
|
|
17
|
+
'REDIS_USERNAME',
|
|
18
|
+
'REDIS_PASSWORD',
|
|
19
|
+
'CLIENT_URL',
|
|
20
|
+
];
|
|
21
|
+
const missing = requiredVars.filter(v => !process.env[v]);
|
|
22
|
+
|
|
23
|
+
if (missing.length > 0) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Missing required environment variables in ${envFilePath}: ${missing.join(', ')}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const env = {
|
|
30
|
+
port: Number(process.env.PORT),
|
|
31
|
+
supabaseUrl: process.env.SUPABASE_URL as string,
|
|
32
|
+
supabaseKey: process.env.SUPABASE_KEY as string,
|
|
33
|
+
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY as string,
|
|
34
|
+
nodeEnv: process.env.NODE_ENV as string,
|
|
35
|
+
redisHost: process.env.REDIS_HOST as string,
|
|
36
|
+
redisPort: Number(process.env.REDIS_PORT),
|
|
37
|
+
redisUsername: process.env.REDIS_USERNAME as string,
|
|
38
|
+
redisPassword: process.env.REDIS_PASSWORD as string,
|
|
39
|
+
sendGridApiKey: process.env.SENDGRID_API_KEY as string,
|
|
40
|
+
senderEmail: process.env.SENDER_EMAIL as string,
|
|
41
|
+
clientUrl: process.env.CLIENT_URL as string,
|
|
42
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { env } from './env.config.js';
|
|
2
|
+
import pino from 'pino';
|
|
3
|
+
|
|
4
|
+
const loggerOptions = {
|
|
5
|
+
level: env.nodeEnv === 'production' ? 'info' : 'debug',
|
|
6
|
+
redact: ['req.headers.authorization', 'body.password'],
|
|
7
|
+
...(env.nodeEnv !== 'production' && {
|
|
8
|
+
transport: { target: 'pino-pretty', options: { colorize: true } },
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const logger = pino(loggerOptions);
|
|
13
|
+
|
|
14
|
+
export default logger;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Queue } from "bullmq";
|
|
2
|
+
import { bullConnection } from "./redis.config.js";
|
|
3
|
+
|
|
4
|
+
export const mainQueue = new Queue("main-app-queue", {
|
|
5
|
+
connection: bullConnection,
|
|
6
|
+
defaultJobOptions: {
|
|
7
|
+
attempts: 3,
|
|
8
|
+
backoff: { type: "exponential", delay: 1000 },
|
|
9
|
+
removeOnComplete: true,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Universal helper to add jobs
|
|
15
|
+
*/
|
|
16
|
+
export const dispatch = (name: string, data: any) => {
|
|
17
|
+
return mainQueue.add(name, data);
|
|
18
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createClient } from 'redis';
|
|
2
|
+
import { env } from "./env.config.js";
|
|
3
|
+
import logger from './logger.config.js';
|
|
4
|
+
|
|
5
|
+
export const redisClient = createClient({
|
|
6
|
+
username: env.redisUsername,
|
|
7
|
+
password: env.redisPassword,
|
|
8
|
+
socket: {
|
|
9
|
+
host: env.redisHost,
|
|
10
|
+
port: env.redisPort,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const bullConnection = {
|
|
15
|
+
url: env.redisHost + ':' + env.redisPort,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
redisClient.on('error', (err: any) =>
|
|
19
|
+
logger.error({ err }, 'Redis Client Error'),
|
|
20
|
+
);
|
|
21
|
+
await redisClient.connect();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import { env } from './env.config.js';
|
|
3
|
+
|
|
4
|
+
const supabaseUrl = env.supabaseUrl;
|
|
5
|
+
const supabaseKey = env.supabaseKey;
|
|
6
|
+
const supabaseServiceRoleKey = env.supabaseServiceRoleKey;
|
|
7
|
+
|
|
8
|
+
export const supabase = createClient(supabaseUrl, supabaseKey);
|
|
9
|
+
|
|
10
|
+
export const supabaseAdmin =
|
|
11
|
+
supabaseServiceRoleKey ?
|
|
12
|
+
createClient(supabaseUrl, supabaseServiceRoleKey)
|
|
13
|
+
: null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { env } from "../config/env.config.js";
|
|
2
|
+
import logger from "../config/logger.config.js";
|
|
3
|
+
import sgMail from "../config/sendgrid.config.js";
|
|
4
|
+
|
|
5
|
+
interface EmailOptions {
|
|
6
|
+
to: string | string[];
|
|
7
|
+
subject: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
html: string;
|
|
10
|
+
isMultiple?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class MailService {
|
|
14
|
+
private static readonly SENDER_EMAIL = env.senderEmail;
|
|
15
|
+
|
|
16
|
+
static async send(options: EmailOptions) {
|
|
17
|
+
const { to, isMultiple = true } = options;
|
|
18
|
+
|
|
19
|
+
const msg = {
|
|
20
|
+
to: options.to,
|
|
21
|
+
from: this.SENDER_EMAIL,
|
|
22
|
+
subject: options.subject,
|
|
23
|
+
text: options.text || options.subject,
|
|
24
|
+
html: options.html,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (Array.isArray(to) && isMultiple) {
|
|
29
|
+
return await sgMail.sendMultiple(msg);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return await sgMail.send(msg);
|
|
33
|
+
} catch (error: any) {
|
|
34
|
+
logger.error(
|
|
35
|
+
{ err: error },
|
|
36
|
+
'SendGrid Error:',
|
|
37
|
+
error.response?.body || error.message,
|
|
38
|
+
);
|
|
39
|
+
throw new Error("Failed to send email");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { HttpCode } from "../types/types.js";
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
public readonly status: string;
|
|
5
|
+
public readonly statusCode: HttpCode;
|
|
6
|
+
public readonly isOperational: boolean;
|
|
7
|
+
|
|
8
|
+
constructor(message: string, statusCode: HttpCode) {
|
|
9
|
+
super(message);
|
|
10
|
+
Object.setPrototypeOf(this, ApiError.prototype);
|
|
11
|
+
|
|
12
|
+
this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
|
|
13
|
+
this.statusCode = statusCode;
|
|
14
|
+
this.isOperational = true;
|
|
15
|
+
|
|
16
|
+
Error.captureStackTrace(this, this.constructor);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { HttpCode } from '../types/types.js';
|
|
3
|
+
import { ApiError } from './apiError.js';
|
|
4
|
+
import logger from '../config/logger.config.js';
|
|
5
|
+
|
|
6
|
+
export const globalErrorHandler = (
|
|
7
|
+
err: Error,
|
|
8
|
+
_req: Request,
|
|
9
|
+
res: Response,
|
|
10
|
+
_next: NextFunction,
|
|
11
|
+
): void => {
|
|
12
|
+
let error = err;
|
|
13
|
+
|
|
14
|
+
if (typeof err === 'object' && err !== null && 'name' in err) {
|
|
15
|
+
const name = (err as any).name;
|
|
16
|
+
if (name === 'JsonWebTokenError') {
|
|
17
|
+
error = new ApiError('Token is invalid', HttpCode.UNAUTHORIZED_ACCESS);
|
|
18
|
+
} else if (name === 'TokenExpiredError') {
|
|
19
|
+
error = new ApiError('Token expired', HttpCode.UNAUTHORIZED_ACCESS);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!(error instanceof ApiError)) {
|
|
24
|
+
// CAPTURE THE ORIGINAL MESSAGE AND STACK BEFORE OVERWRITING
|
|
25
|
+
const originalMessage = error.message || 'An unexpected error occurred';
|
|
26
|
+
const originalStack = error.stack;
|
|
27
|
+
|
|
28
|
+
error = new ApiError(
|
|
29
|
+
originalMessage, // Use the real error message in Dev
|
|
30
|
+
HttpCode.INTERNAL_SERVER_ERROR,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Restore the original stack trace so you can debug
|
|
34
|
+
if (originalStack) error.stack = originalStack;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
38
|
+
const appError = error as ApiError;
|
|
39
|
+
|
|
40
|
+
if (nodeEnv === 'development') {
|
|
41
|
+
res.status(appError.statusCode).json({
|
|
42
|
+
status: appError.status,
|
|
43
|
+
message: appError.message,
|
|
44
|
+
stack: appError.stack,
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
if (appError.isOperational) {
|
|
48
|
+
res.status(appError.statusCode).json({
|
|
49
|
+
status: appError.status,
|
|
50
|
+
message: appError.message,
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
logger.error({ err: appError }, 'ERROR');
|
|
54
|
+
res.status(500).json({
|
|
55
|
+
status: 'Error',
|
|
56
|
+
message: 'Something went wrong',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This middleware ensures every request has a unique ID.
|
|
3
|
+
* It checks if the API Gateway already provided one (via x-request-id);
|
|
4
|
+
* otherwise, it generates a new one.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Request, Response } from 'express';
|
|
8
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
9
|
+
import pinoHttp from 'pino-http';
|
|
10
|
+
import logger from '../config/logger.config.js';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export const pinoMiddleware = pinoHttp.default({
|
|
14
|
+
logger,
|
|
15
|
+
genReqId: (req: Request) => req.headers['x-request-id'] || uuidv4(),
|
|
16
|
+
customSuccessMessage: (req: Request, _res: Response) =>
|
|
17
|
+
`Request completed: ${req.method} ${req.url}`,
|
|
18
|
+
customErrorMessage: (_req: Request, _res: Response, err: any) =>
|
|
19
|
+
`Request failed: ${err.message}`,
|
|
20
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ErrorObject } from "ajv";
|
|
2
|
+
|
|
3
|
+
export type ValidatorError =
|
|
4
|
+
| ErrorObject<string, Record<string, any>, unknown>
|
|
5
|
+
| { instancePath: string; message: string };
|
|
6
|
+
|
|
7
|
+
// Helper function to safely extract ajv error message
|
|
8
|
+
export const errorMessage = (errors: ValidatorError[] | null | undefined) => {
|
|
9
|
+
return errors
|
|
10
|
+
?.map((e) => {
|
|
11
|
+
if (e.instancePath === "") {
|
|
12
|
+
return e.message;
|
|
13
|
+
}
|
|
14
|
+
return e.instancePath.replace("/", "") + " : " + e.message;
|
|
15
|
+
})
|
|
16
|
+
.join(", ");
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
//global type-safe request handler
|
|
20
|
+
declare global {
|
|
21
|
+
namespace Express {
|
|
22
|
+
interface Request {
|
|
23
|
+
user: {
|
|
24
|
+
id: string;
|
|
25
|
+
email: string;
|
|
26
|
+
role: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"rootDir": "./src",
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"module": "nodenext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"target": "ES2022",
|
|
8
|
+
"lib": ["ES2022", "DOM"],
|
|
9
|
+
"types": ["node"],
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"exactOptionalPropertyTypes": true,
|
|
15
|
+
"noImplicitReturns": true,
|
|
16
|
+
"noUnusedLocals": true,
|
|
17
|
+
"noUnusedParameters": true,
|
|
18
|
+
"strict": true,
|
|
19
|
+
"esModuleInterop": true,
|
|
20
|
+
"isolatedModules": true,
|
|
21
|
+
"noUncheckedSideEffectImports": true,
|
|
22
|
+
"moduleDetection": "force",
|
|
23
|
+
"skipLibCheck": true
|
|
24
|
+
},
|
|
25
|
+
"include": ["src/**/*"],
|
|
26
|
+
"exclude": ["node_modules", "dist"]
|
|
27
|
+
}
|