@tndhuy/create-app 1.1.4 → 1.2.3
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/dist/cli.js +86 -10
- package/package.json +1 -1
- package/templates/mongo/README.md +128 -0
- package/templates/mongo/docker-compose.yml +76 -4
- package/templates/mongo/gitignore.template +51 -0
- package/templates/mongo/prometheus.yml +7 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.ts +11 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.ts +28 -0
- package/templates/mongo/src/main.ts +10 -1
- package/templates/mongo/src/shared/logger/pino.config.ts +4 -0
- package/templates/postgres/README.md +76 -0
- package/templates/postgres/docker-compose.yml +107 -0
- package/templates/postgres/gitignore.template +51 -0
- package/templates/postgres/package.json +1 -2
- package/templates/postgres/prometheus.yml +7 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.ts +11 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.ts +28 -0
- package/templates/postgres/src/infrastructure/health/prisma.health-indicator.ts +6 -0
- package/templates/postgres/src/main.ts +10 -1
- package/templates/postgres/src/shared/logger/pino.config.ts +4 -0
package/dist/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ function toPascalCase(kebab) {
|
|
|
40
40
|
function buildReplacements(serviceName) {
|
|
41
41
|
return [
|
|
42
42
|
["nestjs-backend-template", serviceName],
|
|
43
|
+
["{{SERVICE_NAME_KEBAB}}", serviceName],
|
|
43
44
|
["NestjsBackendTemplate", toPascalCase(serviceName)]
|
|
44
45
|
];
|
|
45
46
|
}
|
|
@@ -158,18 +159,60 @@ async function collectPaths(dir) {
|
|
|
158
159
|
}
|
|
159
160
|
return results;
|
|
160
161
|
}
|
|
161
|
-
async function replaceFileContents(dir, replacements) {
|
|
162
|
+
async function replaceFileContents(dir, replacements, options) {
|
|
162
163
|
const entries = await (0, import_promises2.readdir)(dir, { withFileTypes: true });
|
|
163
164
|
for (const entry of entries) {
|
|
164
165
|
const fullPath = (0, import_path2.join)(dir, entry.name);
|
|
165
166
|
if (entry.isDirectory()) {
|
|
166
|
-
await replaceFileContents(fullPath, replacements);
|
|
167
|
+
await replaceFileContents(fullPath, replacements, options);
|
|
167
168
|
} else {
|
|
168
169
|
const binary = await isBinaryFile(fullPath);
|
|
169
170
|
if (binary) continue;
|
|
170
171
|
try {
|
|
171
172
|
let content = await (0, import_promises2.readFile)(fullPath, "utf-8");
|
|
172
173
|
let modified = false;
|
|
174
|
+
const isPrisma = options.orm === "prisma";
|
|
175
|
+
const isMongoose = options.orm === "mongoose";
|
|
176
|
+
const hasRedis = options.modules.includes("redis");
|
|
177
|
+
const hasOtel = options.modules.includes("otel");
|
|
178
|
+
const hasKafka = options.modules.includes("kafka");
|
|
179
|
+
const isPostgres = options.db === "postgres";
|
|
180
|
+
const isMongo = options.db === "mongo";
|
|
181
|
+
const checkBlock = (content2, flag, tag) => {
|
|
182
|
+
const startTag = `{{#IF_${tag}}}`;
|
|
183
|
+
const endTag = `{{/IF_${tag}}}`;
|
|
184
|
+
const startNotTag = `{{#IF_NOT_${tag}}}`;
|
|
185
|
+
const endNotTag = `{{/IF_NOT_${tag}}}`;
|
|
186
|
+
if (content2.includes(startTag)) {
|
|
187
|
+
if (flag) {
|
|
188
|
+
content2 = content2.replaceAll(startTag, "").replaceAll(endTag, "");
|
|
189
|
+
} else {
|
|
190
|
+
const escapedStart = startTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
191
|
+
const escapedEnd = endTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
192
|
+
const regex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, "g");
|
|
193
|
+
content2 = content2.replace(regex, "");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (content2.includes(startNotTag)) {
|
|
197
|
+
if (!flag) {
|
|
198
|
+
content2 = content2.replaceAll(startNotTag, "").replaceAll(endNotTag, "");
|
|
199
|
+
} else {
|
|
200
|
+
const escapedStart = startNotTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
201
|
+
const escapedEnd = endNotTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
202
|
+
const regex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, "g");
|
|
203
|
+
content2 = content2.replace(regex, "");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return content2;
|
|
207
|
+
};
|
|
208
|
+
content = checkBlock(content, isPrisma, "PRISMA");
|
|
209
|
+
content = checkBlock(content, isMongoose, "MONGOOSE");
|
|
210
|
+
content = checkBlock(content, hasRedis, "REDIS");
|
|
211
|
+
content = checkBlock(content, hasOtel, "OTEL");
|
|
212
|
+
content = checkBlock(content, hasKafka, "KAFKA");
|
|
213
|
+
content = checkBlock(content, isPostgres, "POSTGRES");
|
|
214
|
+
content = checkBlock(content, isMongo, "MONGO");
|
|
215
|
+
modified = content !== await (0, import_promises2.readFile)(fullPath, "utf-8");
|
|
173
216
|
for (const [from, to] of replacements) {
|
|
174
217
|
if (content.includes(from)) {
|
|
175
218
|
content = content.replaceAll(from, to);
|
|
@@ -216,8 +259,21 @@ async function patchPackageJson(destDir, options) {
|
|
|
216
259
|
const raw = await (0, import_promises2.readFile)(pkgPath, "utf-8");
|
|
217
260
|
const pkg = JSON.parse(raw);
|
|
218
261
|
pkg.name = options.serviceName;
|
|
262
|
+
delete pkg.workspaces;
|
|
219
263
|
const PRISMA_LATEST = "^7.5.0";
|
|
220
264
|
const PRISMA_MONGO_COMPAT = "^6.0.0";
|
|
265
|
+
if (options.orm === "prisma") {
|
|
266
|
+
if (!pkg.scripts) pkg.scripts = {};
|
|
267
|
+
pkg.scripts["db:generate"] = "prisma generate";
|
|
268
|
+
pkg.scripts["db:push"] = "prisma db push";
|
|
269
|
+
pkg.scripts["db:pull"] = "prisma db pull";
|
|
270
|
+
pkg.scripts["db:studio"] = "prisma studio";
|
|
271
|
+
pkg.scripts["db:format"] = "prisma format";
|
|
272
|
+
if (options.db === "postgres") {
|
|
273
|
+
pkg.scripts["db:migrate:dev"] = "prisma migrate dev";
|
|
274
|
+
pkg.scripts["db:migrate:deploy"] = "prisma migrate deploy";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
221
277
|
if (options.db === "mongo") {
|
|
222
278
|
if (options.orm === "prisma") {
|
|
223
279
|
if (pkg.dependencies) {
|
|
@@ -393,8 +449,11 @@ async function removeOtel(destDir) {
|
|
|
393
449
|
// Remove: import otelSdk from './instrumentation';
|
|
394
450
|
/^import\s+\w+\s+from\s+['"][^'"]*instrumentation['"];\n?/m,
|
|
395
451
|
// Remove: otelSdk?.start(); line
|
|
396
|
-
/^\s*\w+Sdk\?\.start\(\);\n?/m
|
|
452
|
+
/^\s*\w+Sdk\?\.start\(\);\n?/m,
|
|
453
|
+
// Remove shutdown logic: void otelSdk?.shutdown()...
|
|
454
|
+
/^\s*void\s+otelSdk\?\.shutdown\(\)\.catch\(\(\)\s*=>\s*undefined\);\n?/m
|
|
397
455
|
]);
|
|
456
|
+
await safeDeleteFile(destDir, (0, import_path2.join)(destDir, "prometheus.yml"));
|
|
398
457
|
}
|
|
399
458
|
async function addKafka(destDir, serviceName) {
|
|
400
459
|
await generateKafkaModule(destDir, serviceName);
|
|
@@ -464,7 +523,7 @@ async function scaffold(options) {
|
|
|
464
523
|
}
|
|
465
524
|
await (0, import_promises2.cp)(templateDir, destDir, { recursive: true });
|
|
466
525
|
const replacements = buildReplacements(serviceName);
|
|
467
|
-
await replaceFileContents(destDir, replacements);
|
|
526
|
+
await replaceFileContents(destDir, replacements, options);
|
|
468
527
|
await renamePathsWithPlaceholders(destDir, replacements);
|
|
469
528
|
await patchPackageJson(destDir, options);
|
|
470
529
|
if (options.db === "mongo" && options.orm === "prisma") {
|
|
@@ -504,6 +563,10 @@ async function scaffold(options) {
|
|
|
504
563
|
if (modules.includes("kafka")) {
|
|
505
564
|
await addKafka(destDir, serviceName);
|
|
506
565
|
}
|
|
566
|
+
const templateGitignore = (0, import_path2.join)(destDir, "gitignore.template");
|
|
567
|
+
if (await pathExists(templateGitignore)) {
|
|
568
|
+
await (0, import_promises2.rename)(templateGitignore, (0, import_path2.join)(destDir, ".gitignore"));
|
|
569
|
+
}
|
|
507
570
|
}
|
|
508
571
|
|
|
509
572
|
// src/cli.ts
|
|
@@ -535,6 +598,7 @@ async function main() {
|
|
|
535
598
|
};
|
|
536
599
|
if (positionalName && !validateServiceName(positionalName)) {
|
|
537
600
|
config.serviceName = positionalName;
|
|
601
|
+
(0, import_prompts.note)(`Using service name: ${config.serviceName} (from arguments)`, "Info");
|
|
538
602
|
}
|
|
539
603
|
let step = config.serviceName ? 1 : 0;
|
|
540
604
|
const totalSteps = 4;
|
|
@@ -697,8 +761,9 @@ Modules : ${selectedModules}`,
|
|
|
697
761
|
}
|
|
698
762
|
}
|
|
699
763
|
const installDeps = guardCancel(await (0, import_prompts.confirm)({ message: "Install dependencies now?", initialValue: true }));
|
|
764
|
+
let pkgManager = "npm";
|
|
700
765
|
if (installDeps) {
|
|
701
|
-
|
|
766
|
+
pkgManager = guardCancel(await (0, import_prompts.select)({
|
|
702
767
|
message: "Select package manager",
|
|
703
768
|
options: [
|
|
704
769
|
{ value: "pnpm", label: "pnpm", hint: "recommended" },
|
|
@@ -711,17 +776,28 @@ Modules : ${selectedModules}`,
|
|
|
711
776
|
try {
|
|
712
777
|
await (0, import_execa.execa)(pkgManager, ["install"], { cwd: destDir, stdio: "inherit" });
|
|
713
778
|
is.stop("Dependencies installed successfully.");
|
|
779
|
+
if (config.orm === "prisma") {
|
|
780
|
+
const ps = (0, import_prompts.spinner)();
|
|
781
|
+
ps.start("Generating Prisma Client...");
|
|
782
|
+
try {
|
|
783
|
+
await (0, import_execa.execa)(pkgManager, ["run", "db:generate"], { cwd: destDir });
|
|
784
|
+
ps.stop("Prisma Client generated successfully.");
|
|
785
|
+
} catch (err) {
|
|
786
|
+
ps.stop("Prisma Client generation failed. You may need to run it manually.");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
714
789
|
} catch (err) {
|
|
715
790
|
is.stop("Dependency installation failed.");
|
|
716
791
|
}
|
|
717
792
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
`Next steps:
|
|
793
|
+
(0, import_prompts.outro)(
|
|
794
|
+
`Next steps:
|
|
721
795
|
|
|
722
796
|
cd ${config.serviceName}
|
|
723
|
-
${dryRun ? "" :
|
|
724
|
-
|
|
797
|
+
${dryRun ? "" : ` ${pkgManager} run start:dev
|
|
798
|
+
`}`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
725
801
|
}
|
|
726
802
|
main().catch((err) => {
|
|
727
803
|
(0, import_prompts.cancel)(err instanceof Error ? err.message : String(err));
|
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# nestjs-backend-template
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
- Production-ready NestJS 11 template with Domain-Driven Design (DDD) architecture — four layers (Domain, Application, Infrastructure, Presenter) with CQRS via `@nestjs/cqrs`
|
|
6
|
+
- Pre-configured infrastructure: Prisma ORM, ioredis with circuit breaker, OpenTelemetry tracing and Prometheus metrics, Scalar API docs at `/docs`
|
|
7
|
+
- Agent-ready: Claude Code (`.claude/`) and Antigravity (`.agent/`) configs pre-wired with codebase context files and GSD skill
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
1. **Clone the repository**
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone <repo-url> my-service && cd my-service
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
2. **Install dependencies**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
3. **Configure environment**
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cp .env.example .env
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Edit `.env` and set at minimum:
|
|
30
|
+
- `DATABASE_URL` — MongoDB connection string
|
|
31
|
+
- `REDIS_URL` — Redis connection string
|
|
32
|
+
|
|
33
|
+
4. **Start infrastructure**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
docker compose up -d
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Starts MongoDB and Redis locally.
|
|
40
|
+
|
|
41
|
+
5. **Start the server**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pnpm dev
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Server starts at http://localhost:3000. API docs available at http://localhost:3000/docs (login: `admin` / `admin`).
|
|
48
|
+
|
|
49
|
+
## Available Scripts
|
|
50
|
+
|
|
51
|
+
| Script | Description |
|
|
52
|
+
|--------|-------------|
|
|
53
|
+
| `pnpm start:dev` | Start in watch mode (development) |
|
|
54
|
+
| `pnpm build` | Compile TypeScript to `dist/` |
|
|
55
|
+
| `pnpm start:prod` | Run compiled build (`dist/src/main`) |
|
|
56
|
+
| `pnpm test` | Run unit tests with Jest |
|
|
57
|
+
| `pnpm test:cov` | Run tests with coverage report |
|
|
58
|
+
| `pnpm test:e2e` | Run end-to-end tests |
|
|
59
|
+
| `pnpm lint` | Run ESLint with auto-fix |
|
|
60
|
+
| `pnpm format` | Run Prettier on `src/` and `test/` |
|
|
61
|
+
| `pnpm db:generate` | Generate Prisma Client from schema |
|
|
62
|
+
| `pnpm db:sync` | Sync Prisma schema to MongoDB (`prisma db push`) |
|
|
63
|
+
| `pnpm db:sync:force` | Force sync schema with destructive changes (`--accept-data-loss`) |
|
|
64
|
+
| `pnpm db:push` | Push schema changes directly to database (no migration files) |
|
|
65
|
+
| `pnpm db:pull` | Pull database schema into `prisma/schema/` |
|
|
66
|
+
| `pnpm db:validate` | Validate Prisma schema and datasource config |
|
|
67
|
+
| `pnpm db:format` | Format Prisma schema files under `prisma/schema/` |
|
|
68
|
+
| `pnpm db:studio` | Open Prisma Studio for data browsing/editing |
|
|
69
|
+
|
|
70
|
+
## MongoDB Schema Workflow
|
|
71
|
+
|
|
72
|
+
Prisma migrations are not used for MongoDB in this template.
|
|
73
|
+
|
|
74
|
+
1. Edit schema files in `prisma/schema/`
|
|
75
|
+
2. Run `pnpm db:format`
|
|
76
|
+
3. Run `pnpm db:validate`
|
|
77
|
+
4. Run `pnpm db:sync` (or `pnpm db:sync:force` when explicitly needed)
|
|
78
|
+
5. Run `pnpm db:generate`
|
|
79
|
+
|
|
80
|
+
## Environment Variables
|
|
81
|
+
|
|
82
|
+
| Variable | Required | Description | Default |
|
|
83
|
+
|----------|----------|-------------|---------|
|
|
84
|
+
| `PORT` | No | HTTP server port | `3000` |
|
|
85
|
+
| `NODE_ENV` | No | Environment (`development` / `production`) | `development` |
|
|
86
|
+
| `DATABASE_URL` | Yes | MongoDB connection string | — |
|
|
87
|
+
| `REDIS_URL` | Yes | Redis connection string | — |
|
|
88
|
+
| `API_VERSION` | No | API version for URI prefix | `1` |
|
|
89
|
+
| `APP_NAME` | No | Application name shown in Scalar docs | `NestJS Backend Template` |
|
|
90
|
+
| `APP_DESCRIPTION` | No | API description shown in Scalar docs | `API Documentation` |
|
|
91
|
+
| `REQUEST_TIMEOUT` | No | Request timeout in milliseconds | `30000` |
|
|
92
|
+
| `THROTTLE_TTL` | No | Rate limit window in seconds | `60` |
|
|
93
|
+
| `THROTTLE_LIMIT` | No | Max requests per window | `100` |
|
|
94
|
+
| `DOCS_USER` | No | Basic auth username for `/docs` | `admin` |
|
|
95
|
+
| `DOCS_PASS` | No | Basic auth password for `/docs` | `admin` |
|
|
96
|
+
| `OTEL_ENABLED` | No | Enable OpenTelemetry tracing and metrics | `false` |
|
|
97
|
+
| `OTEL_SERVICE_NAME` | No | Service name reported to OTel collector | `nestjs-backend-template` |
|
|
98
|
+
| `OTEL_PROMETHEUS_PORT` | No | Port for Prometheus metrics scrape endpoint | `9464` |
|
|
99
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | No | OTLP HTTP endpoint for trace export | `http://localhost:4318` |
|
|
100
|
+
| `LARK_APP_ID` | No | Lark app ID for MCP integration (agent use) | — |
|
|
101
|
+
| `LARK_APP_SECRET` | No | Lark app secret for MCP integration (agent use) | — |
|
|
102
|
+
|
|
103
|
+
## Architecture
|
|
104
|
+
|
|
105
|
+
This template follows Domain-Driven Design (DDD) with four layers: Domain, Application, Infrastructure, and Presenter. Each domain module is fully self-contained under `src/<module>/` and communicates through the CQRS bus.
|
|
106
|
+
|
|
107
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full guide including layer rules, data flow, and the `ExampleModule` walkthrough.
|
|
108
|
+
|
|
109
|
+
## Adding a New Module
|
|
110
|
+
|
|
111
|
+
Each new domain module follows the same DDD structure as `src/example/`. Follow the step-by-step walkthrough in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
|
|
112
|
+
|
|
113
|
+
## Tech Stack
|
|
114
|
+
|
|
115
|
+
- **NestJS** 11.0.1 — framework (Express 5 platform)
|
|
116
|
+
- **Prisma** 7.5.0 — MongoDB ORM with schema sync (`db push`)
|
|
117
|
+
- **ioredis** 5.10.1 — Redis client with cockatiel circuit breaker
|
|
118
|
+
- **Pino** / nestjs-pino — structured JSON logging
|
|
119
|
+
- **OpenTelemetry** SDK — distributed tracing (OTLP) and Prometheus metrics
|
|
120
|
+
- **Scalar** (@scalar/nestjs-api-reference) — interactive API docs
|
|
121
|
+
- **class-validator** / **class-transformer** — DTO validation and transformation
|
|
122
|
+
- **@nestjs/cqrs** 11.0.3 — command and query bus
|
|
123
|
+
- **@nestjs/throttler** 6.5.0 — global rate limiting
|
|
124
|
+
- **TypeScript** 5.7.3
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
UNLICENSED
|
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
services:
|
|
2
|
+
{{#IF_POSTGRES}}
|
|
3
|
+
postgres:
|
|
4
|
+
image: postgres:16-alpine
|
|
5
|
+
container_name: {{SERVICE_NAME_KEBAB}}-postgres
|
|
6
|
+
restart: unless-stopped
|
|
7
|
+
ports:
|
|
8
|
+
- "5432:5432"
|
|
9
|
+
environment:
|
|
10
|
+
POSTGRES_USER: user
|
|
11
|
+
POSTGRES_PASSWORD: password
|
|
12
|
+
POSTGRES_DB: template_db
|
|
13
|
+
volumes:
|
|
14
|
+
- postgres_data:/var/lib/postgresql/data
|
|
15
|
+
healthcheck:
|
|
16
|
+
test: ["CMD-SHELL", "pg_isready -U user -d template_db"]
|
|
17
|
+
interval: 10s
|
|
18
|
+
timeout: 5s
|
|
19
|
+
retries: 5
|
|
20
|
+
{{/IF_POSTGRES}}
|
|
21
|
+
|
|
22
|
+
{{#IF_MONGO}}
|
|
2
23
|
mongodb:
|
|
3
24
|
image: mongo:latest
|
|
4
|
-
container_name:
|
|
25
|
+
container_name: {{SERVICE_NAME_KEBAB}}-mongodb
|
|
5
26
|
restart: unless-stopped
|
|
6
27
|
ports:
|
|
7
28
|
- "27017:27017"
|
|
@@ -13,11 +34,13 @@ services:
|
|
|
13
34
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
14
35
|
interval: 10s
|
|
15
36
|
timeout: 5s
|
|
16
|
-
retries:
|
|
37
|
+
retries: 5
|
|
38
|
+
{{/IF_MONGO}}
|
|
17
39
|
|
|
40
|
+
{{#IF_REDIS}}
|
|
18
41
|
redis:
|
|
19
42
|
image: redis:latest
|
|
20
|
-
container_name:
|
|
43
|
+
container_name: {{SERVICE_NAME_KEBAB}}-redis
|
|
21
44
|
restart: unless-stopped
|
|
22
45
|
ports:
|
|
23
46
|
- "6379:6379"
|
|
@@ -28,8 +51,57 @@ services:
|
|
|
28
51
|
test: ["CMD", "redis-cli", "ping"]
|
|
29
52
|
interval: 10s
|
|
30
53
|
timeout: 5s
|
|
31
|
-
retries:
|
|
54
|
+
retries: 5
|
|
55
|
+
{{/IF_REDIS}}
|
|
56
|
+
|
|
57
|
+
{{#IF_KAFKA}}
|
|
58
|
+
zookeeper:
|
|
59
|
+
image: bitnami/zookeeper:latest
|
|
60
|
+
container_name: {{SERVICE_NAME_KEBAB}}-zookeeper
|
|
61
|
+
ports:
|
|
62
|
+
- "2181:2181"
|
|
63
|
+
environment:
|
|
64
|
+
- ALLOW_ANONYMOUS_LOGIN=yes
|
|
65
|
+
|
|
66
|
+
kafka:
|
|
67
|
+
image: bitnami/kafka:latest
|
|
68
|
+
container_name: {{SERVICE_NAME_KEBAB}}-kafka
|
|
69
|
+
ports:
|
|
70
|
+
- "9092:9092"
|
|
71
|
+
environment:
|
|
72
|
+
- KAFKA_BROKER_ID=1
|
|
73
|
+
- KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
|
|
74
|
+
- ALLOW_PLAINTEXT_LISTENER=yes
|
|
75
|
+
- KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
|
|
76
|
+
depends_on:
|
|
77
|
+
- zookeeper
|
|
78
|
+
{{/IF_KAFKA}}
|
|
79
|
+
|
|
80
|
+
{{#IF_OTEL}}
|
|
81
|
+
jaeger:
|
|
82
|
+
image: jaegertracing/all-in-one:latest
|
|
83
|
+
container_name: {{SERVICE_NAME_KEBAB}}-jaeger
|
|
84
|
+
ports:
|
|
85
|
+
- "16686:16686"
|
|
86
|
+
- "4317:4317"
|
|
87
|
+
- "4318:4318"
|
|
88
|
+
|
|
89
|
+
prometheus:
|
|
90
|
+
image: prom/prometheus:latest
|
|
91
|
+
container_name: {{SERVICE_NAME_KEBAB}}-prometheus
|
|
92
|
+
ports:
|
|
93
|
+
- "9090:9090"
|
|
94
|
+
volumes:
|
|
95
|
+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
|
96
|
+
{{/IF_OTEL}}
|
|
32
97
|
|
|
33
98
|
volumes:
|
|
99
|
+
{{#IF_POSTGRES}}
|
|
100
|
+
postgres_data:
|
|
101
|
+
{{/IF_POSTGRES}}
|
|
102
|
+
{{#IF_MONGO}}
|
|
34
103
|
mongodb_data:
|
|
104
|
+
{{/IF_MONGO}}
|
|
105
|
+
{{#IF_REDIS}}
|
|
35
106
|
redis_data:
|
|
107
|
+
{{/IF_REDIS}}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# compiled output
|
|
2
|
+
/dist
|
|
3
|
+
/node_modules
|
|
4
|
+
/build
|
|
5
|
+
|
|
6
|
+
# Logs
|
|
7
|
+
logs
|
|
8
|
+
*.log
|
|
9
|
+
npm-debug.log*
|
|
10
|
+
pnpm-debug.log*
|
|
11
|
+
yarn-debug.log*
|
|
12
|
+
yarn-error.log*
|
|
13
|
+
lerna-debug.log*
|
|
14
|
+
|
|
15
|
+
# OS
|
|
16
|
+
.DS_Store
|
|
17
|
+
|
|
18
|
+
# Tests
|
|
19
|
+
/coverage
|
|
20
|
+
/.nyc_output
|
|
21
|
+
|
|
22
|
+
# IDEs and editors
|
|
23
|
+
/.idea
|
|
24
|
+
.project
|
|
25
|
+
.classpath
|
|
26
|
+
.c9/
|
|
27
|
+
*.launch
|
|
28
|
+
.settings/
|
|
29
|
+
*.sublime-workspace
|
|
30
|
+
|
|
31
|
+
# IDE - VSCode
|
|
32
|
+
.vscode/*
|
|
33
|
+
!.vscode/settings.json
|
|
34
|
+
!.vscode/tasks.json
|
|
35
|
+
!.vscode/launch.json
|
|
36
|
+
!.vscode/extensions.json
|
|
37
|
+
|
|
38
|
+
# Environment files
|
|
39
|
+
.env
|
|
40
|
+
.env.development.local
|
|
41
|
+
.env.test.local
|
|
42
|
+
.env.production.local
|
|
43
|
+
.env.local
|
|
44
|
+
|
|
45
|
+
# TypeScript incremental compilation
|
|
46
|
+
*.tsbuildinfo
|
|
47
|
+
|
|
48
|
+
# Claude Code memory files
|
|
49
|
+
**/.claude/
|
|
50
|
+
**/CLAUDE.md
|
|
51
|
+
!/CLAUDE.md
|
|
@@ -4,11 +4,22 @@ import { Observable } from 'rxjs';
|
|
|
4
4
|
import { map } from 'rxjs/operators';
|
|
5
5
|
import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
|
|
6
6
|
|
|
7
|
+
export const RAW_RESPONSE_KEY = 'raw_response';
|
|
8
|
+
|
|
7
9
|
@Injectable()
|
|
8
10
|
export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
|
|
9
11
|
constructor(private readonly reflector: Reflector) {}
|
|
10
12
|
|
|
11
13
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
14
|
+
const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
|
|
15
|
+
context.getHandler(),
|
|
16
|
+
context.getClass(),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
if (isRaw) {
|
|
20
|
+
return next.handle();
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
|
|
13
24
|
context.getHandler(),
|
|
14
25
|
context.getClass(),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
|
2
2
|
import Redis from 'ioredis';
|
|
3
3
|
import { circuitBreaker, ConsecutiveBreaker, CircuitBreakerPolicy, handleAll } from 'cockatiel';
|
|
4
|
+
{{#IF_OTEL}}
|
|
4
5
|
import { trace } from '@opentelemetry/api';
|
|
5
6
|
|
|
6
7
|
const tracer = trace.getTracer('redis-service');
|
|
8
|
+
{{/IF_OTEL}}
|
|
7
9
|
|
|
8
10
|
@Injectable()
|
|
9
11
|
export class RedisService implements OnModuleDestroy {
|
|
@@ -46,6 +48,7 @@ export class RedisService implements OnModuleDestroy {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
async get(key: string): Promise<string | null> {
|
|
51
|
+
{{#IF_OTEL}}
|
|
49
52
|
return tracer.startActiveSpan('redis.get', async (span) => {
|
|
50
53
|
try {
|
|
51
54
|
span.setAttribute('db.system', 'redis');
|
|
@@ -59,9 +62,14 @@ export class RedisService implements OnModuleDestroy {
|
|
|
59
62
|
span.end();
|
|
60
63
|
}
|
|
61
64
|
});
|
|
65
|
+
{{/IF_OTEL}}
|
|
66
|
+
{{#IF_NOT_OTEL}}
|
|
67
|
+
return this.execute((c) => c.get(key));
|
|
68
|
+
{{/IF_NOT_OTEL}}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
|
72
|
+
{{#IF_OTEL}}
|
|
65
73
|
return tracer.startActiveSpan('redis.set', async (span) => {
|
|
66
74
|
try {
|
|
67
75
|
span.setAttribute('db.system', 'redis');
|
|
@@ -84,9 +92,20 @@ export class RedisService implements OnModuleDestroy {
|
|
|
84
92
|
span.end();
|
|
85
93
|
}
|
|
86
94
|
});
|
|
95
|
+
{{/IF_OTEL}}
|
|
96
|
+
{{#IF_NOT_OTEL}}
|
|
97
|
+
await this.execute(async (c) => {
|
|
98
|
+
if (ttlSeconds) {
|
|
99
|
+
await c.set(key, value, 'EX', ttlSeconds);
|
|
100
|
+
} else {
|
|
101
|
+
await c.set(key, value);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
{{/IF_NOT_OTEL}}
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
async del(key: string): Promise<void> {
|
|
108
|
+
{{#IF_OTEL}}
|
|
90
109
|
return tracer.startActiveSpan('redis.del', async (span) => {
|
|
91
110
|
try {
|
|
92
111
|
span.setAttribute('db.system', 'redis');
|
|
@@ -100,9 +119,14 @@ export class RedisService implements OnModuleDestroy {
|
|
|
100
119
|
span.end();
|
|
101
120
|
}
|
|
102
121
|
});
|
|
122
|
+
{{/IF_OTEL}}
|
|
123
|
+
{{#IF_NOT_OTEL}}
|
|
124
|
+
await this.execute((c) => c.del(key).then(() => undefined));
|
|
125
|
+
{{/IF_NOT_OTEL}}
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
async expire(key: string, ttlSeconds: number): Promise<void> {
|
|
129
|
+
{{#IF_OTEL}}
|
|
106
130
|
return tracer.startActiveSpan('redis.expire', async (span) => {
|
|
107
131
|
try {
|
|
108
132
|
span.setAttribute('db.system', 'redis');
|
|
@@ -117,5 +141,9 @@ export class RedisService implements OnModuleDestroy {
|
|
|
117
141
|
span.end();
|
|
118
142
|
}
|
|
119
143
|
});
|
|
144
|
+
{{/IF_OTEL}}
|
|
145
|
+
{{#IF_NOT_OTEL}}
|
|
146
|
+
await this.execute((c) => c.expire(key, ttlSeconds).then(() => undefined));
|
|
147
|
+
{{/IF_NOT_OTEL}}
|
|
120
148
|
}
|
|
121
149
|
}
|
|
@@ -84,10 +84,19 @@ async function bootstrap() {
|
|
|
84
84
|
const port = parseInt(process.env.PORT ?? '3000', 10);
|
|
85
85
|
const httpServer = await app.listen(port);
|
|
86
86
|
|
|
87
|
+
const logger = app.get(Logger);
|
|
88
|
+
logger.log(`🚀 Application is running on: http://localhost:${port}`);
|
|
89
|
+
logger.log(`📖 API Documentation (Scalar): http://localhost:${port}/docs`);
|
|
90
|
+
logger.log(`📄 API Spec (JSON): http://localhost:${port}/docs/json`);
|
|
91
|
+
|
|
87
92
|
process.on('SIGTERM', () => {
|
|
88
93
|
loggerService.log('SIGTERM signal received: closing HTTP server');
|
|
89
94
|
setTimeout(() => {
|
|
90
|
-
|
|
95
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
97
|
+
const otelSdk = require('./instrumentation').default;
|
|
98
|
+
void otelSdk?.shutdown().catch(() => undefined);
|
|
99
|
+
}
|
|
91
100
|
httpServer.close(() => {
|
|
92
101
|
loggerService.log('HTTP server closed');
|
|
93
102
|
process.exit(0);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
import type { Params } from 'nestjs-pino';
|
|
3
|
+
{{#IF_OTEL}}
|
|
3
4
|
import { trace, context, isSpanContextValid } from '@opentelemetry/api';
|
|
5
|
+
{{/IF_OTEL}}
|
|
4
6
|
|
|
5
7
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
6
8
|
const LOG_DIR = join(process.cwd(), 'logs');
|
|
@@ -11,6 +13,7 @@ export const pinoConfig: Params = {
|
|
|
11
13
|
level: isDev ? 'debug' : 'info',
|
|
12
14
|
// Inject Trace context into every log line
|
|
13
15
|
mixin() {
|
|
16
|
+
{{#IF_OTEL}}
|
|
14
17
|
const activeSpan = trace.getSpan(context.active());
|
|
15
18
|
if (activeSpan) {
|
|
16
19
|
const spanContext = activeSpan.spanContext();
|
|
@@ -22,6 +25,7 @@ export const pinoConfig: Params = {
|
|
|
22
25
|
};
|
|
23
26
|
}
|
|
24
27
|
}
|
|
28
|
+
{{/IF_OTEL}}
|
|
25
29
|
return {};
|
|
26
30
|
},
|
|
27
31
|
// Standardize on X-Request-Id as the primary req.id
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# {{SERVICE_NAME_KEBAB}}
|
|
2
|
+
|
|
3
|
+
Production-ready NestJS 11 backend service based on DDD architecture.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
This project follows **Domain-Driven Design (DDD)** principles with four distinct layers:
|
|
8
|
+
|
|
9
|
+
1. **Domain:** Core logic, Entities, Value Objects, and Repository interfaces. (No framework dependencies)
|
|
10
|
+
2. **Application:** Command/Query handlers (CQRS), DTOs, and application services.
|
|
11
|
+
3. **Infrastructure:** Database implementations (Prisma/Mongoose), Redis cache, and external integrations.
|
|
12
|
+
4. **Presenter:** Controllers (HTTP/RPC), Interceptors, and Filters.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Pino Logging:** High-performance logging with request context tracking.
|
|
17
|
+
- **Request ID:** Every request is assigned a unique `X-Request-Id` header for traceability.
|
|
18
|
+
- **OpenTelemetry:** Distributed tracing and Prometheus metrics (if enabled).
|
|
19
|
+
- **Circuit Breaker:** Resilience for Redis and external calls using Cockatiel.
|
|
20
|
+
- **API Reference:** Interactive Scalar documentation available at `/docs`.
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### 1. Configure Environment
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cp .env.example .env
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. Infrastructure
|
|
31
|
+
|
|
32
|
+
Start database and cache using Docker:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
docker compose up -d
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 3. Database Setup
|
|
39
|
+
|
|
40
|
+
{{#IF_PRISMA}}
|
|
41
|
+
```bash
|
|
42
|
+
# Generate Prisma Client
|
|
43
|
+
npm run db:generate
|
|
44
|
+
|
|
45
|
+
# Apply migrations
|
|
46
|
+
npm run db:migrate:deploy
|
|
47
|
+
```
|
|
48
|
+
{{/IF_PRISMA}}
|
|
49
|
+
{{#IF_MONGOOSE}}
|
|
50
|
+
Ensure your `MONGODB_URL` is correct in `.env`.
|
|
51
|
+
{{/IF_MONGOOSE}}
|
|
52
|
+
|
|
53
|
+
### 4. Running the App
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Development
|
|
57
|
+
npm run start:dev
|
|
58
|
+
|
|
59
|
+
# Production build
|
|
60
|
+
npm run build
|
|
61
|
+
npm run start:prod
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Logging & Observability
|
|
65
|
+
|
|
66
|
+
- **Console:** Pretty-printed logs in development mode.
|
|
67
|
+
- **Files:** Logs are automatically rotated and stored in the `logs/` directory.
|
|
68
|
+
- **Correlation:** Search for `requestId` in logs to trace all logs for a specific user request.
|
|
69
|
+
|
|
70
|
+
## Available Scripts
|
|
71
|
+
|
|
72
|
+
- `npm run build`: Compile the project.
|
|
73
|
+
- `npm run start:dev`: Start in watch mode.
|
|
74
|
+
- `npm run test`: Run unit tests.
|
|
75
|
+
- `npm run test:e2e`: Run end-to-end tests.
|
|
76
|
+
- `npm run lint`: Fix code style issues.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
services:
|
|
2
|
+
{{#IF_POSTGRES}}
|
|
3
|
+
postgres:
|
|
4
|
+
image: postgres:16-alpine
|
|
5
|
+
container_name: {{SERVICE_NAME_KEBAB}}-postgres
|
|
6
|
+
restart: unless-stopped
|
|
7
|
+
ports:
|
|
8
|
+
- "5432:5432"
|
|
9
|
+
environment:
|
|
10
|
+
POSTGRES_USER: user
|
|
11
|
+
POSTGRES_PASSWORD: password
|
|
12
|
+
POSTGRES_DB: template_db
|
|
13
|
+
volumes:
|
|
14
|
+
- postgres_data:/var/lib/postgresql/data
|
|
15
|
+
healthcheck:
|
|
16
|
+
test: ["CMD-SHELL", "pg_isready -U user -d template_db"]
|
|
17
|
+
interval: 10s
|
|
18
|
+
timeout: 5s
|
|
19
|
+
retries: 5
|
|
20
|
+
{{/IF_POSTGRES}}
|
|
21
|
+
|
|
22
|
+
{{#IF_MONGO}}
|
|
23
|
+
mongodb:
|
|
24
|
+
image: mongo:latest
|
|
25
|
+
container_name: {{SERVICE_NAME_KEBAB}}-mongodb
|
|
26
|
+
restart: unless-stopped
|
|
27
|
+
ports:
|
|
28
|
+
- "27017:27017"
|
|
29
|
+
environment:
|
|
30
|
+
MONGO_INITDB_DATABASE: template_db
|
|
31
|
+
volumes:
|
|
32
|
+
- mongodb_data:/data/db
|
|
33
|
+
healthcheck:
|
|
34
|
+
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
35
|
+
interval: 10s
|
|
36
|
+
timeout: 5s
|
|
37
|
+
retries: 5
|
|
38
|
+
{{/IF_MONGO}}
|
|
39
|
+
|
|
40
|
+
{{#IF_REDIS}}
|
|
41
|
+
redis:
|
|
42
|
+
image: redis:latest
|
|
43
|
+
container_name: {{SERVICE_NAME_KEBAB}}-redis
|
|
44
|
+
restart: unless-stopped
|
|
45
|
+
ports:
|
|
46
|
+
- "6379:6379"
|
|
47
|
+
command: ["redis-server", "--appendonly", "yes"]
|
|
48
|
+
volumes:
|
|
49
|
+
- redis_data:/data
|
|
50
|
+
healthcheck:
|
|
51
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
52
|
+
interval: 10s
|
|
53
|
+
timeout: 5s
|
|
54
|
+
retries: 5
|
|
55
|
+
{{/IF_REDIS}}
|
|
56
|
+
|
|
57
|
+
{{#IF_KAFKA}}
|
|
58
|
+
zookeeper:
|
|
59
|
+
image: bitnami/zookeeper:latest
|
|
60
|
+
container_name: {{SERVICE_NAME_KEBAB}}-zookeeper
|
|
61
|
+
ports:
|
|
62
|
+
- "2181:2181"
|
|
63
|
+
environment:
|
|
64
|
+
- ALLOW_ANONYMOUS_LOGIN=yes
|
|
65
|
+
|
|
66
|
+
kafka:
|
|
67
|
+
image: bitnami/kafka:latest
|
|
68
|
+
container_name: {{SERVICE_NAME_KEBAB}}-kafka
|
|
69
|
+
ports:
|
|
70
|
+
- "9092:9092"
|
|
71
|
+
environment:
|
|
72
|
+
- KAFKA_BROKER_ID=1
|
|
73
|
+
- KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
|
|
74
|
+
- ALLOW_PLAINTEXT_LISTENER=yes
|
|
75
|
+
- KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
|
|
76
|
+
depends_on:
|
|
77
|
+
- zookeeper
|
|
78
|
+
{{/IF_KAFKA}}
|
|
79
|
+
|
|
80
|
+
{{#IF_OTEL}}
|
|
81
|
+
jaeger:
|
|
82
|
+
image: jaegertracing/all-in-one:latest
|
|
83
|
+
container_name: {{SERVICE_NAME_KEBAB}}-jaeger
|
|
84
|
+
ports:
|
|
85
|
+
- "16686:16686"
|
|
86
|
+
- "4317:4317"
|
|
87
|
+
- "4318:4318"
|
|
88
|
+
|
|
89
|
+
prometheus:
|
|
90
|
+
image: prom/prometheus:latest
|
|
91
|
+
container_name: {{SERVICE_NAME_KEBAB}}-prometheus
|
|
92
|
+
ports:
|
|
93
|
+
- "9090:9090"
|
|
94
|
+
volumes:
|
|
95
|
+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
|
96
|
+
{{/IF_OTEL}}
|
|
97
|
+
|
|
98
|
+
volumes:
|
|
99
|
+
{{#IF_POSTGRES}}
|
|
100
|
+
postgres_data:
|
|
101
|
+
{{/IF_POSTGRES}}
|
|
102
|
+
{{#IF_MONGO}}
|
|
103
|
+
mongodb_data:
|
|
104
|
+
{{/IF_MONGO}}
|
|
105
|
+
{{#IF_REDIS}}
|
|
106
|
+
redis_data:
|
|
107
|
+
{{/IF_REDIS}}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# compiled output
|
|
2
|
+
dist/
|
|
3
|
+
node_modules/
|
|
4
|
+
/build
|
|
5
|
+
|
|
6
|
+
# Logs
|
|
7
|
+
logs
|
|
8
|
+
*.log
|
|
9
|
+
npm-debug.log*
|
|
10
|
+
pnpm-debug.log*
|
|
11
|
+
yarn-debug.log*
|
|
12
|
+
yarn-error.log*
|
|
13
|
+
lerna-debug.log*
|
|
14
|
+
|
|
15
|
+
# OS
|
|
16
|
+
.DS_Store
|
|
17
|
+
|
|
18
|
+
# Tests
|
|
19
|
+
/coverage
|
|
20
|
+
/.nyc_output
|
|
21
|
+
|
|
22
|
+
# IDEs and editors
|
|
23
|
+
/.idea
|
|
24
|
+
.project
|
|
25
|
+
.classpath
|
|
26
|
+
.c9/
|
|
27
|
+
*.launch
|
|
28
|
+
.settings/
|
|
29
|
+
*.sublime-workspace
|
|
30
|
+
|
|
31
|
+
# IDE - VSCode
|
|
32
|
+
.vscode/*
|
|
33
|
+
!.vscode/settings.json
|
|
34
|
+
!.vscode/tasks.json
|
|
35
|
+
!.vscode/launch.json
|
|
36
|
+
!.vscode/extensions.json
|
|
37
|
+
|
|
38
|
+
# Environment files
|
|
39
|
+
.env
|
|
40
|
+
.env.development.local
|
|
41
|
+
.env.test.local
|
|
42
|
+
.env.production.local
|
|
43
|
+
.env.local
|
|
44
|
+
|
|
45
|
+
# TypeScript incremental compilation
|
|
46
|
+
*.tsbuildinfo
|
|
47
|
+
|
|
48
|
+
# Do not add .npmrc files with auth tokens
|
|
49
|
+
# packages/create-app/.npmrc is committed intentionally (uses ${NODE_AUTH_TOKEN} placeholder, no literal token)
|
|
50
|
+
.npmrc
|
|
51
|
+
!packages/create-app/.npmrc
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": "",
|
|
6
6
|
"private": true,
|
|
7
|
-
"license": "
|
|
7
|
+
"license": "MIT",
|
|
8
8
|
"workspaces": [
|
|
9
9
|
"packages/*"
|
|
10
10
|
],
|
|
@@ -63,7 +63,6 @@
|
|
|
63
63
|
"@nestjs/schematics": "^11.0.0",
|
|
64
64
|
"@nestjs/testing": "^11.0.1",
|
|
65
65
|
"@types/express": "^5.0.0",
|
|
66
|
-
"@types/ioredis": "^5.0.0",
|
|
67
66
|
"@types/jest": "^30.0.0",
|
|
68
67
|
"@types/node": "^22.10.7",
|
|
69
68
|
"@types/supertest": "^6.0.2",
|
|
@@ -4,11 +4,22 @@ import { Observable } from 'rxjs';
|
|
|
4
4
|
import { map } from 'rxjs/operators';
|
|
5
5
|
import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
|
|
6
6
|
|
|
7
|
+
export const RAW_RESPONSE_KEY = 'raw_response';
|
|
8
|
+
|
|
7
9
|
@Injectable()
|
|
8
10
|
export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
|
|
9
11
|
constructor(private readonly reflector: Reflector) {}
|
|
10
12
|
|
|
11
13
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
14
|
+
const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
|
|
15
|
+
context.getHandler(),
|
|
16
|
+
context.getClass(),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
if (isRaw) {
|
|
20
|
+
return next.handle();
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
|
|
13
24
|
context.getHandler(),
|
|
14
25
|
context.getClass(),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
|
2
2
|
import Redis from 'ioredis';
|
|
3
3
|
import { circuitBreaker, ConsecutiveBreaker, CircuitBreakerPolicy, handleAll } from 'cockatiel';
|
|
4
|
+
{{#IF_OTEL}}
|
|
4
5
|
import { trace } from '@opentelemetry/api';
|
|
5
6
|
|
|
6
7
|
const tracer = trace.getTracer('redis-service');
|
|
8
|
+
{{/IF_OTEL}}
|
|
7
9
|
|
|
8
10
|
@Injectable()
|
|
9
11
|
export class RedisService implements OnModuleDestroy {
|
|
@@ -46,6 +48,7 @@ export class RedisService implements OnModuleDestroy {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
async get(key: string): Promise<string | null> {
|
|
51
|
+
{{#IF_OTEL}}
|
|
49
52
|
return tracer.startActiveSpan('redis.get', async (span) => {
|
|
50
53
|
try {
|
|
51
54
|
span.setAttribute('db.system', 'redis');
|
|
@@ -59,9 +62,14 @@ export class RedisService implements OnModuleDestroy {
|
|
|
59
62
|
span.end();
|
|
60
63
|
}
|
|
61
64
|
});
|
|
65
|
+
{{/IF_OTEL}}
|
|
66
|
+
{{#IF_NOT_OTEL}}
|
|
67
|
+
return this.execute((c) => c.get(key));
|
|
68
|
+
{{/IF_NOT_OTEL}}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
|
72
|
+
{{#IF_OTEL}}
|
|
65
73
|
return tracer.startActiveSpan('redis.set', async (span) => {
|
|
66
74
|
try {
|
|
67
75
|
span.setAttribute('db.system', 'redis');
|
|
@@ -84,9 +92,20 @@ export class RedisService implements OnModuleDestroy {
|
|
|
84
92
|
span.end();
|
|
85
93
|
}
|
|
86
94
|
});
|
|
95
|
+
{{/IF_OTEL}}
|
|
96
|
+
{{#IF_NOT_OTEL}}
|
|
97
|
+
await this.execute(async (c) => {
|
|
98
|
+
if (ttlSeconds) {
|
|
99
|
+
await c.set(key, value, 'EX', ttlSeconds);
|
|
100
|
+
} else {
|
|
101
|
+
await c.set(key, value);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
{{/IF_NOT_OTEL}}
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
async del(key: string): Promise<void> {
|
|
108
|
+
{{#IF_OTEL}}
|
|
90
109
|
return tracer.startActiveSpan('redis.del', async (span) => {
|
|
91
110
|
try {
|
|
92
111
|
span.setAttribute('db.system', 'redis');
|
|
@@ -100,9 +119,14 @@ export class RedisService implements OnModuleDestroy {
|
|
|
100
119
|
span.end();
|
|
101
120
|
}
|
|
102
121
|
});
|
|
122
|
+
{{/IF_OTEL}}
|
|
123
|
+
{{#IF_NOT_OTEL}}
|
|
124
|
+
await this.execute((c) => c.del(key).then(() => undefined));
|
|
125
|
+
{{/IF_NOT_OTEL}}
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
async expire(key: string, ttlSeconds: number): Promise<void> {
|
|
129
|
+
{{#IF_OTEL}}
|
|
106
130
|
return tracer.startActiveSpan('redis.expire', async (span) => {
|
|
107
131
|
try {
|
|
108
132
|
span.setAttribute('db.system', 'redis');
|
|
@@ -117,5 +141,9 @@ export class RedisService implements OnModuleDestroy {
|
|
|
117
141
|
span.end();
|
|
118
142
|
}
|
|
119
143
|
});
|
|
144
|
+
{{/IF_OTEL}}
|
|
145
|
+
{{#IF_NOT_OTEL}}
|
|
146
|
+
await this.execute((c) => c.expire(key, ttlSeconds).then(() => undefined));
|
|
147
|
+
{{/IF_NOT_OTEL}}
|
|
120
148
|
}
|
|
121
149
|
}
|
|
@@ -10,7 +10,13 @@ export class PrismaHealthIndicator extends HealthIndicator {
|
|
|
10
10
|
|
|
11
11
|
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
|
12
12
|
try {
|
|
13
|
+
{{#IF_POSTGRES}}
|
|
13
14
|
await this.prisma.$queryRaw`SELECT 1`;
|
|
15
|
+
{{/IF_POSTGRES}}
|
|
16
|
+
{{#IF_MONGO}}
|
|
17
|
+
// Prisma on MongoDB uses $runCommandRaw for ping or simple check
|
|
18
|
+
await this.prisma.$runCommandRaw({ ping: 1 });
|
|
19
|
+
{{/IF_MONGO}}
|
|
14
20
|
return this.getStatus(key, true);
|
|
15
21
|
} catch (error) {
|
|
16
22
|
throw new HealthCheckError('Database check failed', this.getStatus(key, false));
|
|
@@ -84,10 +84,19 @@ async function bootstrap() {
|
|
|
84
84
|
const port = parseInt(process.env.PORT ?? '3000', 10);
|
|
85
85
|
const httpServer = await app.listen(port);
|
|
86
86
|
|
|
87
|
+
const logger = app.get(Logger);
|
|
88
|
+
logger.log(`🚀 Application is running on: http://localhost:${port}`);
|
|
89
|
+
logger.log(`📖 API Documentation (Scalar): http://localhost:${port}/docs`);
|
|
90
|
+
logger.log(`📄 API Spec (JSON): http://localhost:${port}/docs/json`);
|
|
91
|
+
|
|
87
92
|
process.on('SIGTERM', () => {
|
|
88
93
|
loggerService.log('SIGTERM signal received: closing HTTP server');
|
|
89
94
|
setTimeout(() => {
|
|
90
|
-
|
|
95
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
97
|
+
const otelSdk = require('./instrumentation').default;
|
|
98
|
+
void otelSdk?.shutdown().catch(() => undefined);
|
|
99
|
+
}
|
|
91
100
|
httpServer.close(() => {
|
|
92
101
|
loggerService.log('HTTP server closed');
|
|
93
102
|
process.exit(0);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
import type { Params } from 'nestjs-pino';
|
|
3
|
+
{{#IF_OTEL}}
|
|
3
4
|
import { trace, context, isSpanContextValid } from '@opentelemetry/api';
|
|
5
|
+
{{/IF_OTEL}}
|
|
4
6
|
|
|
5
7
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
6
8
|
const LOG_DIR = join(process.cwd(), 'logs');
|
|
@@ -11,6 +13,7 @@ export const pinoConfig: Params = {
|
|
|
11
13
|
level: isDev ? 'debug' : 'info',
|
|
12
14
|
// Inject Trace context into every log line
|
|
13
15
|
mixin() {
|
|
16
|
+
{{#IF_OTEL}}
|
|
14
17
|
const activeSpan = trace.getSpan(context.active());
|
|
15
18
|
if (activeSpan) {
|
|
16
19
|
const spanContext = activeSpan.spanContext();
|
|
@@ -22,6 +25,7 @@ export const pinoConfig: Params = {
|
|
|
22
25
|
};
|
|
23
26
|
}
|
|
24
27
|
}
|
|
28
|
+
{{/IF_OTEL}}
|
|
25
29
|
return {};
|
|
26
30
|
},
|
|
27
31
|
// Standardize on X-Request-Id as the primary req.id
|