@tndhuy/create-app 1.0.0 → 1.2.2
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 +352 -79
- package/package.json +1 -1
- package/templates/mongo/README.md +128 -0
- package/templates/mongo/docker-compose.yml +76 -4
- package/templates/mongo/prometheus.yml +7 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.ts +11 -0
- package/templates/mongo/src/main.ts +5 -1
- package/templates/postgres/README.md +76 -0
- package/templates/postgres/docker-compose.yml +107 -0
- package/templates/postgres/package.json +1 -5
- package/templates/postgres/prisma/schema.prisma +1 -0
- package/templates/postgres/prometheus.yml +7 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.ts +11 -0
- package/templates/postgres/src/main.ts +5 -1
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
26
|
// src/cli.ts
|
|
27
27
|
var import_prompts = require("@clack/prompts");
|
|
28
28
|
var import_path3 = require("path");
|
|
29
|
+
var import_promises3 = require("fs/promises");
|
|
30
|
+
var import_execa = require("execa");
|
|
29
31
|
|
|
30
32
|
// src/scaffold.ts
|
|
31
33
|
var import_promises2 = require("fs/promises");
|
|
@@ -38,6 +40,7 @@ function toPascalCase(kebab) {
|
|
|
38
40
|
function buildReplacements(serviceName) {
|
|
39
41
|
return [
|
|
40
42
|
["nestjs-backend-template", serviceName],
|
|
43
|
+
["{{SERVICE_NAME_KEBAB}}", serviceName],
|
|
41
44
|
["NestjsBackendTemplate", toPascalCase(serviceName)]
|
|
42
45
|
];
|
|
43
46
|
}
|
|
@@ -156,18 +159,48 @@ async function collectPaths(dir) {
|
|
|
156
159
|
}
|
|
157
160
|
return results;
|
|
158
161
|
}
|
|
159
|
-
async function replaceFileContents(dir, replacements) {
|
|
162
|
+
async function replaceFileContents(dir, replacements, options) {
|
|
160
163
|
const entries = await (0, import_promises2.readdir)(dir, { withFileTypes: true });
|
|
161
164
|
for (const entry of entries) {
|
|
162
165
|
const fullPath = (0, import_path2.join)(dir, entry.name);
|
|
163
166
|
if (entry.isDirectory()) {
|
|
164
|
-
await replaceFileContents(fullPath, replacements);
|
|
167
|
+
await replaceFileContents(fullPath, replacements, options);
|
|
165
168
|
} else {
|
|
166
169
|
const binary = await isBinaryFile(fullPath);
|
|
167
170
|
if (binary) continue;
|
|
168
171
|
try {
|
|
169
172
|
let content = await (0, import_promises2.readFile)(fullPath, "utf-8");
|
|
170
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
|
+
if (content2.includes(startTag)) {
|
|
185
|
+
if (flag) {
|
|
186
|
+
return content2.replaceAll(startTag, "").replaceAll(endTag, "");
|
|
187
|
+
} else {
|
|
188
|
+
const escapedStart = startTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
189
|
+
const escapedEnd = endTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
|
|
190
|
+
const regex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, "g");
|
|
191
|
+
return content2.replace(regex, "");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return content2;
|
|
195
|
+
};
|
|
196
|
+
content = checkBlock(content, isPrisma, "PRISMA");
|
|
197
|
+
content = checkBlock(content, isMongoose, "MONGOOSE");
|
|
198
|
+
content = checkBlock(content, hasRedis, "REDIS");
|
|
199
|
+
content = checkBlock(content, hasOtel, "OTEL");
|
|
200
|
+
content = checkBlock(content, hasKafka, "KAFKA");
|
|
201
|
+
content = checkBlock(content, isPostgres, "POSTGRES");
|
|
202
|
+
content = checkBlock(content, isMongo, "MONGO");
|
|
203
|
+
modified = content !== await (0, import_promises2.readFile)(fullPath, "utf-8");
|
|
171
204
|
for (const [from, to] of replacements) {
|
|
172
205
|
if (content.includes(from)) {
|
|
173
206
|
content = content.replaceAll(from, to);
|
|
@@ -185,8 +218,8 @@ async function replaceFileContents(dir, replacements) {
|
|
|
185
218
|
async function renamePathsWithPlaceholders(dir, replacements) {
|
|
186
219
|
const allPaths = await collectPaths(dir);
|
|
187
220
|
allPaths.sort((a, b) => {
|
|
188
|
-
const depthA = a.split(
|
|
189
|
-
const depthB = b.split(
|
|
221
|
+
const depthA = a.split(import_path2.sep).length;
|
|
222
|
+
const depthB = b.split(import_path2.sep).length;
|
|
190
223
|
return depthB - depthA;
|
|
191
224
|
});
|
|
192
225
|
for (const oldPath of allPaths) {
|
|
@@ -214,17 +247,52 @@ async function patchPackageJson(destDir, options) {
|
|
|
214
247
|
const raw = await (0, import_promises2.readFile)(pkgPath, "utf-8");
|
|
215
248
|
const pkg = JSON.parse(raw);
|
|
216
249
|
pkg.name = options.serviceName;
|
|
250
|
+
delete pkg.workspaces;
|
|
251
|
+
const PRISMA_LATEST = "^7.5.0";
|
|
252
|
+
const PRISMA_MONGO_COMPAT = "^6.0.0";
|
|
253
|
+
if (options.orm === "prisma") {
|
|
254
|
+
if (!pkg.scripts) pkg.scripts = {};
|
|
255
|
+
pkg.scripts["db:generate"] = "prisma generate";
|
|
256
|
+
pkg.scripts["db:push"] = "prisma db push";
|
|
257
|
+
pkg.scripts["db:pull"] = "prisma db pull";
|
|
258
|
+
pkg.scripts["db:studio"] = "prisma studio";
|
|
259
|
+
pkg.scripts["db:format"] = "prisma format";
|
|
260
|
+
if (options.db === "postgres") {
|
|
261
|
+
pkg.scripts["db:migrate:dev"] = "prisma migrate dev";
|
|
262
|
+
pkg.scripts["db:migrate:deploy"] = "prisma migrate deploy";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
217
265
|
if (options.db === "mongo") {
|
|
266
|
+
if (options.orm === "prisma") {
|
|
267
|
+
if (pkg.dependencies) {
|
|
268
|
+
delete pkg.dependencies["@prisma/adapter-pg"];
|
|
269
|
+
delete pkg.dependencies["pg"];
|
|
270
|
+
delete pkg.dependencies["@types/pg"];
|
|
271
|
+
pkg.dependencies["@prisma/client"] = PRISMA_MONGO_COMPAT;
|
|
272
|
+
}
|
|
273
|
+
if (pkg.devDependencies) {
|
|
274
|
+
pkg.devDependencies["prisma"] = PRISMA_MONGO_COMPAT;
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
if (pkg.dependencies) {
|
|
278
|
+
delete pkg.dependencies["@prisma/client"];
|
|
279
|
+
delete pkg.dependencies["@prisma/adapter-pg"];
|
|
280
|
+
delete pkg.dependencies["pg"];
|
|
281
|
+
delete pkg.dependencies["@types/pg"];
|
|
282
|
+
pkg.dependencies["mongoose"] = "^8.0.0";
|
|
283
|
+
pkg.dependencies["@nestjs/mongoose"] = "^11.0.0";
|
|
284
|
+
}
|
|
285
|
+
if (pkg.devDependencies) {
|
|
286
|
+
delete pkg.devDependencies["prisma"];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
218
290
|
if (pkg.dependencies) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
delete pkg.dependencies["pg"];
|
|
222
|
-
delete pkg.dependencies["@types/pg"];
|
|
223
|
-
pkg.dependencies["mongoose"] = "^8.0.0";
|
|
224
|
-
pkg.dependencies["@nestjs/mongoose"] = "^11.0.0";
|
|
291
|
+
pkg.dependencies["@prisma/client"] = PRISMA_LATEST;
|
|
292
|
+
pkg.dependencies["@prisma/adapter-pg"] = PRISMA_LATEST;
|
|
225
293
|
}
|
|
226
294
|
if (pkg.devDependencies) {
|
|
227
|
-
|
|
295
|
+
pkg.devDependencies["prisma"] = PRISMA_LATEST;
|
|
228
296
|
}
|
|
229
297
|
}
|
|
230
298
|
if (options.modules.includes("kafka")) {
|
|
@@ -263,7 +331,8 @@ async function pathExists(p) {
|
|
|
263
331
|
async function safeDeleteFile(destDir, filePath) {
|
|
264
332
|
const resolved = (0, import_path2.resolve)(filePath);
|
|
265
333
|
const resolvedDestDir = (0, import_path2.resolve)(destDir);
|
|
266
|
-
|
|
334
|
+
const prefix = resolvedDestDir.endsWith(import_path2.sep) ? resolvedDestDir : resolvedDestDir + import_path2.sep;
|
|
335
|
+
if (!resolved.startsWith(prefix) && resolved !== resolvedDestDir) {
|
|
267
336
|
throw new Error(`Security: path '${filePath}' is outside destDir '${destDir}'`);
|
|
268
337
|
}
|
|
269
338
|
try {
|
|
@@ -274,7 +343,8 @@ async function safeDeleteFile(destDir, filePath) {
|
|
|
274
343
|
async function safeDeleteDir(destDir, dirPath) {
|
|
275
344
|
const resolved = (0, import_path2.resolve)(dirPath);
|
|
276
345
|
const resolvedDestDir = (0, import_path2.resolve)(destDir);
|
|
277
|
-
|
|
346
|
+
const prefix = resolvedDestDir.endsWith(import_path2.sep) ? resolvedDestDir : resolvedDestDir + import_path2.sep;
|
|
347
|
+
if (!resolved.startsWith(prefix) && resolved !== resolvedDestDir) {
|
|
278
348
|
throw new Error(`Security: path '${dirPath}' is outside destDir '${destDir}'`);
|
|
279
349
|
}
|
|
280
350
|
try {
|
|
@@ -367,8 +437,31 @@ async function removeOtel(destDir) {
|
|
|
367
437
|
// Remove: import otelSdk from './instrumentation';
|
|
368
438
|
/^import\s+\w+\s+from\s+['"][^'"]*instrumentation['"];\n?/m,
|
|
369
439
|
// Remove: otelSdk?.start(); line
|
|
370
|
-
/^\s*\w+Sdk\?\.start\(\);\n?/m
|
|
440
|
+
/^\s*\w+Sdk\?\.start\(\);\n?/m,
|
|
441
|
+
// Remove shutdown logic: void otelSdk?.shutdown()...
|
|
442
|
+
/^\s*void\s+otelSdk\?\.shutdown\(\)\.catch\(\(\)\s*=>\s*undefined\);\n?/m
|
|
371
443
|
]);
|
|
444
|
+
const pinoConfigPath = (0, import_path2.join)(destDir, "src", "shared", "logger", "pino.config.ts");
|
|
445
|
+
if (await pathExists(pinoConfigPath)) {
|
|
446
|
+
await removeMatchingLines(pinoConfigPath, [
|
|
447
|
+
// Remove OTel imports
|
|
448
|
+
/^import\s*\{[^}]*trace[^}]*\}\s*from\s*['"]@opentelemetry\/api['"];\n?/m,
|
|
449
|
+
// Remove mixin block (more robust regex for multiline)
|
|
450
|
+
/^\s*mixin\(\)\s*\{[\s\S]*?\n\s*\},\n/m
|
|
451
|
+
]);
|
|
452
|
+
}
|
|
453
|
+
const redisServicePath = (0, import_path2.join)(destDir, "src", "infrastructure", "cache", "redis.service.ts");
|
|
454
|
+
if (await pathExists(redisServicePath)) {
|
|
455
|
+
await removeMatchingLines(redisServicePath, [
|
|
456
|
+
/^import\s*\{[^}]*trace[^}]*\}\s*from\s*['"]@opentelemetry\/api['"];\n?/m,
|
|
457
|
+
// Remove tracing spans from methods
|
|
458
|
+
/^\s*const\s+span\s*=\s*trace\.getTracer[\s\S]*?span\.end\(\);\n/gm,
|
|
459
|
+
// Fallback: remove any remaining span.end() or span related lines
|
|
460
|
+
/^\s*span\.end\(\);\n/gm,
|
|
461
|
+
/^\s*const\s+span\s*=\s*trace\.getTracer.*\n/gm
|
|
462
|
+
]);
|
|
463
|
+
}
|
|
464
|
+
await safeDeleteFile(destDir, (0, import_path2.join)(destDir, "prometheus.yml"));
|
|
372
465
|
}
|
|
373
466
|
async function addKafka(destDir, serviceName) {
|
|
374
467
|
await generateKafkaModule(destDir, serviceName);
|
|
@@ -402,8 +495,8 @@ $1`
|
|
|
402
495
|
/(imports:\s*\[[^\]]*?)(\s*\])/s,
|
|
403
496
|
(match, arrayContent, closing) => {
|
|
404
497
|
const trimmed = arrayContent.trimEnd();
|
|
405
|
-
const
|
|
406
|
-
return `${trimmed}${
|
|
498
|
+
const sep2 = trimmed.endsWith(",") ? "" : ",";
|
|
499
|
+
return `${trimmed}${sep2}
|
|
407
500
|
KafkaModule,${closing}`;
|
|
408
501
|
}
|
|
409
502
|
);
|
|
@@ -425,13 +518,50 @@ $1`
|
|
|
425
518
|
}
|
|
426
519
|
}
|
|
427
520
|
async function scaffold(options) {
|
|
428
|
-
const
|
|
429
|
-
const
|
|
521
|
+
const templateName = options.orm === "prisma" ? "postgres" : options.db;
|
|
522
|
+
const templateDir = (0, import_path2.join)(__dirname, "..", "templates", templateName);
|
|
523
|
+
const { destDir, serviceName, modules, dryRun } = options;
|
|
524
|
+
if (dryRun) {
|
|
525
|
+
console.log(`
|
|
526
|
+
[Dry Run] Would copy template from ${templateName} to ${destDir}`);
|
|
527
|
+
console.log(` [Dry Run] Would replace placeholders for: ${serviceName}`);
|
|
528
|
+
console.log(` [Dry Run] Would apply modules: ${modules.join(", ") || "none"}
|
|
529
|
+
`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
430
532
|
await (0, import_promises2.cp)(templateDir, destDir, { recursive: true });
|
|
431
533
|
const replacements = buildReplacements(serviceName);
|
|
432
|
-
await replaceFileContents(destDir, replacements);
|
|
534
|
+
await replaceFileContents(destDir, replacements, options);
|
|
433
535
|
await renamePathsWithPlaceholders(destDir, replacements);
|
|
434
536
|
await patchPackageJson(destDir, options);
|
|
537
|
+
if (options.db === "mongo" && options.orm === "prisma") {
|
|
538
|
+
const schemaPath = (0, import_path2.join)(destDir, "prisma", "schema.prisma");
|
|
539
|
+
if (await pathExists(schemaPath)) {
|
|
540
|
+
try {
|
|
541
|
+
let content = await (0, import_promises2.readFile)(schemaPath, "utf-8");
|
|
542
|
+
content = content.replace(/provider\s*=\s*["']postgresql["']/g, 'provider = "mongodb"');
|
|
543
|
+
content = content.replace(
|
|
544
|
+
/id\s+String\s+@id/g,
|
|
545
|
+
'id String @id @default(auto()) @map("_id") @db.ObjectId'
|
|
546
|
+
);
|
|
547
|
+
await (0, import_promises2.writeFile)(schemaPath, content, "utf-8");
|
|
548
|
+
} catch (err) {
|
|
549
|
+
console.warn("Warning: Could not update schema.prisma for MongoDB:", err);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const envPath = (0, import_path2.join)(destDir, ".env.example");
|
|
553
|
+
if (await pathExists(envPath)) {
|
|
554
|
+
try {
|
|
555
|
+
let content = await (0, import_promises2.readFile)(envPath, "utf-8");
|
|
556
|
+
content = content.replace(
|
|
557
|
+
/DATABASE_URL=postgresql:\/\/.*/g,
|
|
558
|
+
'DATABASE_URL="mongodb+srv://user:password@cluster.mongodb.net/myDatabase?retryWrites=true&w=majority"'
|
|
559
|
+
);
|
|
560
|
+
await (0, import_promises2.writeFile)(envPath, content, "utf-8");
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
435
565
|
if (!modules.includes("redis")) {
|
|
436
566
|
await removeRedis(destDir);
|
|
437
567
|
}
|
|
@@ -451,81 +581,224 @@ function guardCancel(value) {
|
|
|
451
581
|
}
|
|
452
582
|
return value;
|
|
453
583
|
}
|
|
584
|
+
async function directoryExists(p) {
|
|
585
|
+
try {
|
|
586
|
+
await (0, import_promises3.stat)(p);
|
|
587
|
+
return true;
|
|
588
|
+
} catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
454
592
|
async function main() {
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
const db = guardCancel(
|
|
471
|
-
await (0, import_prompts.select)({
|
|
472
|
-
message: "Select database",
|
|
473
|
-
options: [
|
|
474
|
-
{ value: "postgres", label: "PostgreSQL", hint: "default" },
|
|
475
|
-
{ value: "mongo", label: "MongoDB" }
|
|
476
|
-
]
|
|
477
|
-
})
|
|
478
|
-
);
|
|
479
|
-
const modules = guardCancel(
|
|
480
|
-
await (0, import_prompts.multiselect)({
|
|
481
|
-
message: "Optional modules (space to toggle, enter to confirm)",
|
|
482
|
-
options: [
|
|
483
|
-
{ value: "redis", label: "Redis", hint: "caching + circuit breaker" },
|
|
484
|
-
{ value: "otel", label: "OpenTelemetry", hint: "traces + metrics" },
|
|
485
|
-
{ value: "kafka", label: "Kafka", hint: "message broker boilerplate" }
|
|
486
|
-
],
|
|
487
|
-
required: false
|
|
488
|
-
})
|
|
489
|
-
);
|
|
490
|
-
const selectedModules = modules.length > 0 ? modules.join(", ") : "none";
|
|
491
|
-
console.log("");
|
|
492
|
-
console.log(` Service name : ${serviceName}`);
|
|
493
|
-
console.log(` Database : ${db}`);
|
|
494
|
-
console.log(` Modules : ${selectedModules}`);
|
|
495
|
-
console.log("");
|
|
496
|
-
const proceed = guardCancel(
|
|
497
|
-
await (0, import_prompts.confirm)({
|
|
498
|
-
message: "Scaffold project with these settings?",
|
|
499
|
-
initialValue: true
|
|
500
|
-
})
|
|
501
|
-
);
|
|
502
|
-
if (!proceed) {
|
|
503
|
-
(0, import_prompts.cancel)("Scaffolding cancelled.");
|
|
504
|
-
process.exit(0);
|
|
593
|
+
const args = process.argv.slice(2);
|
|
594
|
+
const dryRun = args.includes("--dry-run");
|
|
595
|
+
const positionalName = args.find((a) => !a.startsWith("--"));
|
|
596
|
+
(0, import_prompts.intro)("create-app -- NestJS DDD scaffolder" + (dryRun ? " [DRY RUN]" : ""));
|
|
597
|
+
const config = {
|
|
598
|
+
serviceName: "",
|
|
599
|
+
db: "postgres",
|
|
600
|
+
orm: "mongoose",
|
|
601
|
+
modules: []
|
|
602
|
+
};
|
|
603
|
+
if (positionalName && !validateServiceName(positionalName)) {
|
|
604
|
+
config.serviceName = positionalName;
|
|
605
|
+
(0, import_prompts.note)(`Using service name: ${config.serviceName} (from arguments)`, "Info");
|
|
505
606
|
}
|
|
506
|
-
|
|
607
|
+
let step = config.serviceName ? 1 : 0;
|
|
608
|
+
const totalSteps = 4;
|
|
609
|
+
while (step < totalSteps) {
|
|
610
|
+
switch (step) {
|
|
611
|
+
case 0: {
|
|
612
|
+
const res = guardCancel(
|
|
613
|
+
await (0, import_prompts.text)({
|
|
614
|
+
message: "Service name (kebab-case)",
|
|
615
|
+
placeholder: "my-service",
|
|
616
|
+
validate: (v) => validateServiceName(v)
|
|
617
|
+
})
|
|
618
|
+
);
|
|
619
|
+
config.serviceName = res;
|
|
620
|
+
step++;
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
case 1: {
|
|
624
|
+
const res = guardCancel(
|
|
625
|
+
await (0, import_prompts.select)({
|
|
626
|
+
message: "Select database",
|
|
627
|
+
options: [
|
|
628
|
+
{ value: "postgres", label: "PostgreSQL", hint: "using Prisma" },
|
|
629
|
+
{ value: "mongo", label: "MongoDB", hint: "choice of Mongoose or Prisma" },
|
|
630
|
+
{ value: "_back", label: "Go Back", hint: "return to service name" }
|
|
631
|
+
]
|
|
632
|
+
})
|
|
633
|
+
);
|
|
634
|
+
if (res === "_back") {
|
|
635
|
+
step--;
|
|
636
|
+
} else {
|
|
637
|
+
config.db = res;
|
|
638
|
+
if (res === "mongo") {
|
|
639
|
+
config.orm = guardCancel(
|
|
640
|
+
await (0, import_prompts.select)({
|
|
641
|
+
message: "Select MongoDB ORM",
|
|
642
|
+
options: [
|
|
643
|
+
{ value: "mongoose", label: "Mongoose", hint: "default for NestJS" },
|
|
644
|
+
{ value: "prisma", label: "Prisma", hint: "v6 compatible mode" }
|
|
645
|
+
]
|
|
646
|
+
})
|
|
647
|
+
);
|
|
648
|
+
} else {
|
|
649
|
+
config.orm = "prisma";
|
|
650
|
+
}
|
|
651
|
+
step++;
|
|
652
|
+
}
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
case 2: {
|
|
656
|
+
const res = guardCancel(
|
|
657
|
+
await (0, import_prompts.multiselect)({
|
|
658
|
+
message: "Optional modules (space to toggle, enter to confirm)",
|
|
659
|
+
options: [
|
|
660
|
+
{ value: "redis", label: "Redis", hint: "caching + circuit breaker" },
|
|
661
|
+
{ value: "otel", label: "OpenTelemetry", hint: "traces + metrics" },
|
|
662
|
+
{ value: "kafka", label: "Kafka", hint: "message broker boilerplate" },
|
|
663
|
+
{ value: "_back", label: "Go Back", hint: "return to database selection" }
|
|
664
|
+
],
|
|
665
|
+
required: false
|
|
666
|
+
})
|
|
667
|
+
);
|
|
668
|
+
if (res.includes("_back")) {
|
|
669
|
+
step--;
|
|
670
|
+
} else {
|
|
671
|
+
config.modules = res;
|
|
672
|
+
step++;
|
|
673
|
+
}
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case 3: {
|
|
677
|
+
const selectedModules = config.modules.length > 0 ? config.modules.join(", ") : "none";
|
|
678
|
+
(0, import_prompts.note)(
|
|
679
|
+
`Service name : ${config.serviceName}
|
|
680
|
+
Database : ${config.db}
|
|
681
|
+
ORM : ${config.orm}
|
|
682
|
+
Modules : ${selectedModules}`,
|
|
683
|
+
"Confirm Project Summary"
|
|
684
|
+
);
|
|
685
|
+
const res = guardCancel(
|
|
686
|
+
await (0, import_prompts.select)({
|
|
687
|
+
message: "Scaffold project with these settings?",
|
|
688
|
+
options: [
|
|
689
|
+
{ value: "confirm", label: "Yes, scaffold project" },
|
|
690
|
+
{ value: "back", label: "No, let me change something", hint: "go back" },
|
|
691
|
+
{ value: "cancel", label: "Cancel", hint: "exit" }
|
|
692
|
+
]
|
|
693
|
+
})
|
|
694
|
+
);
|
|
695
|
+
if (res === "confirm") {
|
|
696
|
+
const destDir2 = (0, import_path3.join)(process.cwd(), config.serviceName);
|
|
697
|
+
if (await directoryExists(destDir2) && !dryRun) {
|
|
698
|
+
const overwrite = guardCancel(
|
|
699
|
+
await (0, import_prompts.select)({
|
|
700
|
+
message: `Directory "${config.serviceName}" already exists.`,
|
|
701
|
+
options: [
|
|
702
|
+
{ value: "overwrite", label: "Overwrite", hint: "danger: deletes existing directory" },
|
|
703
|
+
{ value: "back", label: "Change service name", hint: "go back to step 1" },
|
|
704
|
+
{ value: "cancel", label: "Exit", hint: "cancel" }
|
|
705
|
+
]
|
|
706
|
+
})
|
|
707
|
+
);
|
|
708
|
+
if (overwrite === "overwrite") {
|
|
709
|
+
const confirmDelete = guardCancel(await (0, import_prompts.confirm)({ message: "Are you absolutely sure?", initialValue: false }));
|
|
710
|
+
if (confirmDelete) {
|
|
711
|
+
const s2 = (0, import_prompts.spinner)();
|
|
712
|
+
s2.start("Cleaning up existing directory...");
|
|
713
|
+
await (0, import_promises3.rm)(destDir2, { recursive: true, force: true });
|
|
714
|
+
s2.stop("Directory cleaned.");
|
|
715
|
+
step++;
|
|
716
|
+
}
|
|
717
|
+
} else if (overwrite === "back") {
|
|
718
|
+
step = 0;
|
|
719
|
+
} else {
|
|
720
|
+
(0, import_prompts.cancel)("Operation cancelled.");
|
|
721
|
+
process.exit(0);
|
|
722
|
+
}
|
|
723
|
+
} else {
|
|
724
|
+
step++;
|
|
725
|
+
}
|
|
726
|
+
} else if (res === "back") {
|
|
727
|
+
step--;
|
|
728
|
+
} else {
|
|
729
|
+
(0, import_prompts.cancel)("Scaffolding cancelled.");
|
|
730
|
+
process.exit(0);
|
|
731
|
+
}
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const destDir = (0, import_path3.join)(process.cwd(), config.serviceName);
|
|
507
737
|
const s = (0, import_prompts.spinner)();
|
|
508
738
|
s.start("Scaffolding project...");
|
|
509
739
|
try {
|
|
510
740
|
await scaffold({
|
|
511
|
-
serviceName,
|
|
512
|
-
db,
|
|
513
|
-
|
|
514
|
-
|
|
741
|
+
serviceName: config.serviceName,
|
|
742
|
+
db: config.db,
|
|
743
|
+
orm: config.orm,
|
|
744
|
+
modules: config.modules,
|
|
745
|
+
destDir,
|
|
746
|
+
dryRun
|
|
515
747
|
});
|
|
516
748
|
s.stop("Project scaffolded!");
|
|
517
749
|
} catch (err) {
|
|
518
750
|
s.stop("Scaffolding failed.");
|
|
519
751
|
throw err;
|
|
520
752
|
}
|
|
753
|
+
if (!dryRun) {
|
|
754
|
+
const initGit = guardCancel(await (0, import_prompts.confirm)({ message: "Initialize git repository?", initialValue: true }));
|
|
755
|
+
if (initGit) {
|
|
756
|
+
const gs = (0, import_prompts.spinner)();
|
|
757
|
+
gs.start("Initializing git...");
|
|
758
|
+
try {
|
|
759
|
+
await (0, import_execa.execa)("git", ["init"], { cwd: destDir });
|
|
760
|
+
await (0, import_execa.execa)("git", ["add", "."], { cwd: destDir });
|
|
761
|
+
await (0, import_execa.execa)("git", ["commit", "-m", "chore: initial commit from template"], { cwd: destDir });
|
|
762
|
+
gs.stop("Git initialized with initial commit.");
|
|
763
|
+
} catch (err) {
|
|
764
|
+
gs.stop("Git initialization failed (check if git is installed).");
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const installDeps = guardCancel(await (0, import_prompts.confirm)({ message: "Install dependencies now?", initialValue: true }));
|
|
768
|
+
if (installDeps) {
|
|
769
|
+
const pkgManager = guardCancel(await (0, import_prompts.select)({
|
|
770
|
+
message: "Select package manager",
|
|
771
|
+
options: [
|
|
772
|
+
{ value: "pnpm", label: "pnpm", hint: "recommended" },
|
|
773
|
+
{ value: "npm", label: "npm" },
|
|
774
|
+
{ value: "yarn", label: "yarn" }
|
|
775
|
+
]
|
|
776
|
+
}));
|
|
777
|
+
const is = (0, import_prompts.spinner)();
|
|
778
|
+
is.start(`Installing dependencies using ${pkgManager}...`);
|
|
779
|
+
try {
|
|
780
|
+
await (0, import_execa.execa)(pkgManager, ["install"], { cwd: destDir, stdio: "inherit" });
|
|
781
|
+
is.stop("Dependencies installed successfully.");
|
|
782
|
+
if (config.orm === "prisma") {
|
|
783
|
+
const ps = (0, import_prompts.spinner)();
|
|
784
|
+
ps.start("Generating Prisma Client...");
|
|
785
|
+
try {
|
|
786
|
+
await (0, import_execa.execa)(pkgManager, ["run", "db:generate"], { cwd: destDir });
|
|
787
|
+
ps.stop("Prisma Client generated successfully.");
|
|
788
|
+
} catch (err) {
|
|
789
|
+
ps.stop("Prisma Client generation failed. You may need to run it manually.");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
} catch (err) {
|
|
793
|
+
is.stop("Dependency installation failed.");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
521
797
|
(0, import_prompts.outro)(
|
|
522
798
|
`Next steps:
|
|
523
799
|
|
|
524
|
-
cd ${serviceName}
|
|
525
|
-
npm
|
|
526
|
-
cp .env.example .env
|
|
527
|
-
npm run start:dev
|
|
528
|
-
`
|
|
800
|
+
cd ${config.serviceName}
|
|
801
|
+
${dryRun ? "" : " npm run start:dev\n"}`
|
|
529
802
|
);
|
|
530
803
|
}
|
|
531
804
|
main().catch((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}}
|
|
@@ -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(),
|
|
@@ -87,7 +87,11 @@ async function bootstrap() {
|
|
|
87
87
|
process.on('SIGTERM', () => {
|
|
88
88
|
loggerService.log('SIGTERM signal received: closing HTTP server');
|
|
89
89
|
setTimeout(() => {
|
|
90
|
-
|
|
90
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
+
const otelSdk = require('./instrumentation').default;
|
|
93
|
+
void otelSdk?.shutdown().catch(() => undefined);
|
|
94
|
+
}
|
|
91
95
|
httpServer.close(() => {
|
|
92
96
|
loggerService.log('HTTP server closed');
|
|
93
97
|
process.exit(0);
|
|
@@ -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}}
|
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": "",
|
|
6
6
|
"private": true,
|
|
7
|
-
"license": "
|
|
8
|
-
"workspaces": [
|
|
9
|
-
"packages/*"
|
|
10
|
-
],
|
|
7
|
+
"license": "MIT",
|
|
11
8
|
"scripts": {
|
|
12
9
|
"build": "nest build",
|
|
13
10
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
@@ -63,7 +60,6 @@
|
|
|
63
60
|
"@nestjs/schematics": "^11.0.0",
|
|
64
61
|
"@nestjs/testing": "^11.0.1",
|
|
65
62
|
"@types/express": "^5.0.0",
|
|
66
|
-
"@types/ioredis": "^5.0.0",
|
|
67
63
|
"@types/jest": "^30.0.0",
|
|
68
64
|
"@types/node": "^22.10.7",
|
|
69
65
|
"@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(),
|
|
@@ -87,7 +87,11 @@ async function bootstrap() {
|
|
|
87
87
|
process.on('SIGTERM', () => {
|
|
88
88
|
loggerService.log('SIGTERM signal received: closing HTTP server');
|
|
89
89
|
setTimeout(() => {
|
|
90
|
-
|
|
90
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
+
const otelSdk = require('./instrumentation').default;
|
|
93
|
+
void otelSdk?.shutdown().catch(() => undefined);
|
|
94
|
+
}
|
|
91
95
|
httpServer.close(() => {
|
|
92
96
|
loggerService.log('HTTP server closed');
|
|
93
97
|
process.exit(0);
|