create-forgeon 0.1.29 → 0.1.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { ensureModuleExists } from './registry.mjs';
4
4
  import { writeModuleDocs } from './docs.mjs';
5
5
  import { applyI18nModule } from './i18n.mjs';
6
+ import { applyLoggerModule } from './logger.mjs';
6
7
 
7
8
  function ensureForgeonLikeProject(targetRoot) {
8
9
  const requiredPaths = [
@@ -22,6 +23,7 @@ function ensureForgeonLikeProject(targetRoot) {
22
23
 
23
24
  const MODULE_APPLIERS = {
24
25
  i18n: applyI18nModule,
26
+ logger: applyLoggerModule,
25
27
  };
26
28
 
27
29
  export function applyModulePreset({ moduleId, targetRoot, packageRoot }) {
@@ -243,4 +243,85 @@ describe('addModule', () => {
243
243
  fs.rmSync(targetRoot, { recursive: true, force: true });
244
244
  }
245
245
  });
246
+
247
+ it('applies logger module on top of scaffold without i18n', () => {
248
+ const targetRoot = mkTmp('forgeon-module-logger-');
249
+ const projectRoot = path.join(targetRoot, 'demo-logger');
250
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
251
+
252
+ try {
253
+ scaffoldProject({
254
+ templateRoot,
255
+ packageRoot,
256
+ targetRoot: projectRoot,
257
+ projectName: 'demo-logger',
258
+ frontend: 'react',
259
+ db: 'prisma',
260
+ i18nEnabled: false,
261
+ proxy: 'caddy',
262
+ });
263
+
264
+ const result = addModule({
265
+ moduleId: 'logger',
266
+ targetRoot: projectRoot,
267
+ packageRoot,
268
+ });
269
+
270
+ assert.equal(result.applied, true);
271
+ assert.match(result.message, /applied/);
272
+ assert.equal(
273
+ fs.existsSync(path.join(projectRoot, 'packages', 'logger', 'package.json')),
274
+ true,
275
+ );
276
+
277
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
278
+ assert.match(apiPackage, /@forgeon\/logger/);
279
+ assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
280
+
281
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
282
+ assert.match(appModule, /@forgeon\/logger/);
283
+ assert.match(appModule, /loggerConfig/);
284
+ assert.match(appModule, /loggerEnvSchema/);
285
+ assert.match(appModule, /ForgeonLoggerModule/);
286
+
287
+ const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
288
+ assert.match(mainTs, /ForgeonLoggerService/);
289
+ assert.match(mainTs, /ForgeonHttpLoggingInterceptor/);
290
+ assert.match(mainTs, /bufferLogs: true/);
291
+ assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
292
+ assert.match(mainTs, /app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);/);
293
+
294
+ const apiDockerfile = fs.readFileSync(
295
+ path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
296
+ 'utf8',
297
+ );
298
+ assert.match(apiDockerfile, /COPY packages\/logger\/package\.json packages\/logger\/package\.json/);
299
+ assert.match(apiDockerfile, /COPY packages\/logger packages\/logger/);
300
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/logger build/);
301
+
302
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
303
+ assert.match(apiEnv, /LOGGER_LEVEL=log/);
304
+ assert.match(apiEnv, /LOGGER_HTTP_ENABLED=true/);
305
+ assert.match(apiEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
306
+
307
+ const dockerEnv = fs.readFileSync(
308
+ path.join(projectRoot, 'infra', 'docker', '.env.example'),
309
+ 'utf8',
310
+ );
311
+ assert.match(dockerEnv, /LOGGER_LEVEL=log/);
312
+ assert.match(dockerEnv, /LOGGER_HTTP_ENABLED=true/);
313
+ assert.match(dockerEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
314
+
315
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
316
+ assert.match(compose, /LOGGER_LEVEL: \$\{LOGGER_LEVEL\}/);
317
+ assert.match(compose, /LOGGER_HTTP_ENABLED: \$\{LOGGER_HTTP_ENABLED\}/);
318
+ assert.match(compose, /LOGGER_REQUEST_ID_HEADER: \$\{LOGGER_REQUEST_ID_HEADER\}/);
319
+
320
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
321
+ assert.match(moduleDoc, /Logger/);
322
+ assert.match(moduleDoc, /Status: implemented/);
323
+ } finally {
324
+ fs.rmSync(targetRoot, { recursive: true, force: true });
325
+ }
326
+ });
246
327
  });
@@ -0,0 +1,241 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+
5
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
6
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'logger', relativePath);
7
+ if (!fs.existsSync(source)) {
8
+ throw new Error(`Missing logger preset template: ${source}`);
9
+ }
10
+ const destination = path.join(targetRoot, relativePath);
11
+ copyRecursive(source, destination);
12
+ }
13
+
14
+ function ensureDependency(packageJson, name, version) {
15
+ if (!packageJson.dependencies) {
16
+ packageJson.dependencies = {};
17
+ }
18
+ packageJson.dependencies[name] = version;
19
+ }
20
+
21
+ function ensureLineAfter(content, anchorLine, lineToInsert) {
22
+ if (content.includes(lineToInsert)) {
23
+ return content;
24
+ }
25
+
26
+ const index = content.indexOf(anchorLine);
27
+ if (index < 0) {
28
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
29
+ }
30
+
31
+ const insertAt = index + anchorLine.length;
32
+ return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
33
+ }
34
+
35
+ function ensureLineBefore(content, anchorLine, lineToInsert) {
36
+ if (content.includes(lineToInsert)) {
37
+ return content;
38
+ }
39
+
40
+ const index = content.indexOf(anchorLine);
41
+ if (index < 0) {
42
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
43
+ }
44
+
45
+ return `${content.slice(0, index)}${lineToInsert}\n${content.slice(index)}`;
46
+ }
47
+
48
+ function upsertEnvLines(filePath, lines) {
49
+ let content = '';
50
+ if (fs.existsSync(filePath)) {
51
+ content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
52
+ }
53
+
54
+ const keys = new Set(
55
+ content
56
+ .split('\n')
57
+ .filter(Boolean)
58
+ .map((line) => line.split('=')[0]),
59
+ );
60
+
61
+ const append = [];
62
+ for (const line of lines) {
63
+ const key = line.split('=')[0];
64
+ if (!keys.has(key)) {
65
+ append.push(line);
66
+ }
67
+ }
68
+
69
+ const next =
70
+ append.length > 0 ? `${content.trimEnd()}\n${append.join('\n')}\n` : `${content.trimEnd()}\n`;
71
+ fs.writeFileSync(filePath, next.replace(/^\n/, ''), 'utf8');
72
+ }
73
+
74
+ function patchApiPackage(targetRoot) {
75
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
76
+ if (!fs.existsSync(packagePath)) {
77
+ return;
78
+ }
79
+
80
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
81
+ if (!packageJson.scripts) {
82
+ packageJson.scripts = {};
83
+ }
84
+
85
+ const loggerBuild = 'pnpm --filter @forgeon/logger build';
86
+ const currentPredev = packageJson.scripts.predev;
87
+ if (typeof currentPredev === 'string') {
88
+ if (!currentPredev.includes(loggerBuild)) {
89
+ if (currentPredev.includes('pnpm --filter @forgeon/core build')) {
90
+ packageJson.scripts.predev = currentPredev.replace(
91
+ 'pnpm --filter @forgeon/core build',
92
+ `pnpm --filter @forgeon/core build && ${loggerBuild}`,
93
+ );
94
+ } else {
95
+ packageJson.scripts.predev = `${loggerBuild} && ${currentPredev}`;
96
+ }
97
+ }
98
+ } else {
99
+ packageJson.scripts.predev = loggerBuild;
100
+ }
101
+
102
+ ensureDependency(packageJson, '@forgeon/logger', 'workspace:*');
103
+ writeJson(packagePath, packageJson);
104
+ }
105
+
106
+ function patchMain(targetRoot) {
107
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'main.ts');
108
+ if (!fs.existsSync(filePath)) {
109
+ return;
110
+ }
111
+
112
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
113
+ content = ensureLineBefore(
114
+ content,
115
+ "import { NestFactory } from '@nestjs/core';",
116
+ "import { ForgeonHttpLoggingInterceptor, ForgeonLoggerService } from '@forgeon/logger';",
117
+ );
118
+
119
+ content = content.replace(
120
+ 'const app = await NestFactory.create(AppModule);',
121
+ 'const app = await NestFactory.create(AppModule, { bufferLogs: true });',
122
+ );
123
+
124
+ if (!content.includes('app.useLogger(app.get(ForgeonLoggerService));')) {
125
+ content = content.replace(
126
+ ' const coreConfigService = app.get(CoreConfigService);',
127
+ ` const coreConfigService = app.get(CoreConfigService);
128
+ app.useLogger(app.get(ForgeonLoggerService));
129
+ app.useGlobalInterceptors(app.get(ForgeonHttpLoggingInterceptor));`,
130
+ );
131
+ }
132
+
133
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
134
+ }
135
+
136
+ function patchAppModule(targetRoot) {
137
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
138
+ if (!fs.existsSync(filePath)) {
139
+ return;
140
+ }
141
+
142
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
143
+
144
+ content = ensureLineAfter(
145
+ content,
146
+ "import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
147
+ "import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
148
+ );
149
+
150
+ content = content.replace(
151
+ 'load: [coreConfig, dbPrismaConfig, i18nConfig],',
152
+ 'load: [coreConfig, dbPrismaConfig, i18nConfig, loggerConfig],',
153
+ );
154
+ content = content.replace(
155
+ 'load: [coreConfig, dbPrismaConfig],',
156
+ 'load: [coreConfig, dbPrismaConfig, loggerConfig],',
157
+ );
158
+ content = content.replace(
159
+ 'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema, i18nEnvSchema]),',
160
+ 'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema, i18nEnvSchema, loggerEnvSchema]),',
161
+ );
162
+ content = content.replace(
163
+ 'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema]),',
164
+ 'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema, loggerEnvSchema]),',
165
+ );
166
+
167
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonLoggerModule,');
168
+
169
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
170
+ }
171
+
172
+ function patchApiDockerfile(targetRoot) {
173
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
174
+ if (!fs.existsSync(dockerfilePath)) {
175
+ return;
176
+ }
177
+
178
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
179
+
180
+ content = ensureLineAfter(
181
+ content,
182
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
183
+ 'COPY packages/logger/package.json packages/logger/package.json',
184
+ );
185
+ content = ensureLineAfter(
186
+ content,
187
+ 'COPY packages/db-prisma packages/db-prisma',
188
+ 'COPY packages/logger packages/logger',
189
+ );
190
+
191
+ content = content.replace(/^RUN pnpm --filter @forgeon\/logger build\r?\n?/gm, '');
192
+ content = ensureLineBefore(
193
+ content,
194
+ 'RUN pnpm --filter @forgeon/api prisma:generate',
195
+ 'RUN pnpm --filter @forgeon/logger build',
196
+ );
197
+
198
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
199
+ }
200
+
201
+ function patchCompose(targetRoot) {
202
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
203
+ if (!fs.existsSync(composePath)) {
204
+ return;
205
+ }
206
+
207
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
208
+ if (!content.includes('LOGGER_LEVEL: ${LOGGER_LEVEL}')) {
209
+ content = content.replace(
210
+ /^(\s+API_PREFIX:.*)$/m,
211
+ `$1
212
+ LOGGER_LEVEL: \${LOGGER_LEVEL}
213
+ LOGGER_HTTP_ENABLED: \${LOGGER_HTTP_ENABLED}
214
+ LOGGER_REQUEST_ID_HEADER: \${LOGGER_REQUEST_ID_HEADER}`,
215
+ );
216
+ }
217
+
218
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
219
+ }
220
+
221
+ export function applyLoggerModule({ packageRoot, targetRoot }) {
222
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'logger'));
223
+ patchApiPackage(targetRoot);
224
+ patchMain(targetRoot);
225
+ patchAppModule(targetRoot);
226
+ patchApiDockerfile(targetRoot);
227
+ patchCompose(targetRoot);
228
+
229
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
230
+ 'LOGGER_LEVEL=log',
231
+ 'LOGGER_HTTP_ENABLED=true',
232
+ 'LOGGER_REQUEST_ID_HEADER=x-request-id',
233
+ ]);
234
+
235
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
236
+ 'LOGGER_LEVEL=log',
237
+ 'LOGGER_HTTP_ENABLED=true',
238
+ 'LOGGER_REQUEST_ID_HEADER=x-request-id',
239
+ ]);
240
+ }
241
+
@@ -7,6 +7,14 @@ const MODULE_PRESETS = {
7
7
  description: 'Backend/frontend i18n wiring with locale contracts and translation resources.',
8
8
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
9
9
  },
10
+ logger: {
11
+ id: 'logger',
12
+ label: 'Logger',
13
+ category: 'observability',
14
+ implemented: true,
15
+ description: 'Structured API logger with request id middleware and HTTP logging interceptor.',
16
+ docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
17
+ },
10
18
  'jwt-auth': {
11
19
  id: 'jwt-auth',
12
20
  label: 'JWT Auth',
@@ -0,0 +1,6 @@
1
+ # {{MODULE_LABEL}}
2
+
3
+ - Id: `{{MODULE_ID}}`
4
+ - Category: `{{MODULE_CATEGORY}}`
5
+ - Status: {{MODULE_STATUS}}
6
+
@@ -0,0 +1,10 @@
1
+ ## Overview
2
+
3
+ Adds API logging primitives with a dedicated logger package.
4
+
5
+ Included parts:
6
+ - `@forgeon/logger` package
7
+ - request-id middleware (`x-request-id` by default)
8
+ - HTTP logging interceptor for request/response timing
9
+ - env-driven logger config (`LOGGER_LEVEL`, `LOGGER_HTTP_ENABLED`, `LOGGER_REQUEST_ID_HEADER`)
10
+
@@ -0,0 +1,11 @@
1
+ ## Applied Scope
2
+
3
+ - Adds `packages/logger` workspace package
4
+ - Wires logger config schema into API `ConfigModule` validation/load
5
+ - Registers logger module in `AppModule`
6
+ - Enables Nest app logger and global HTTP logging interceptor in `main.ts`
7
+ - Updates API `predev` script to build logger package
8
+ - Updates API Docker build stages to include `@forgeon/logger`
9
+ - Adds logger env keys to `apps/api/.env.example` and `infra/docker/.env.example`
10
+ - Passes logger env keys through `infra/docker/compose.yml`
11
+
@@ -0,0 +1,4 @@
1
+ ## Status
2
+
3
+ Implemented and applied by `create-forgeon add logger`.
4
+
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@forgeon/logger",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json"
9
+ },
10
+ "dependencies": {
11
+ "@nestjs/common": "^11.0.1",
12
+ "@nestjs/config": "^4.0.2",
13
+ "rxjs": "^7.8.1",
14
+ "zod": "^3.23.8"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.10.7",
18
+ "typescript": "^5.7.3"
19
+ }
20
+ }
21
+
@@ -0,0 +1,17 @@
1
+ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
2
+ import { ForgeonHttpLoggingInterceptor } from './http-logging.interceptor';
3
+ import { ForgeonLoggerService } from './forgeon-logger.service';
4
+ import { LoggerConfigModule } from './logger-config.module';
5
+ import { RequestIdMiddleware } from './request-id.middleware';
6
+
7
+ @Module({
8
+ imports: [LoggerConfigModule],
9
+ providers: [RequestIdMiddleware, ForgeonLoggerService, ForgeonHttpLoggingInterceptor],
10
+ exports: [LoggerConfigModule, ForgeonLoggerService, ForgeonHttpLoggingInterceptor],
11
+ })
12
+ export class ForgeonLoggerModule implements NestModule {
13
+ configure(consumer: MiddlewareConsumer): void {
14
+ consumer.apply(RequestIdMiddleware).forRoutes('*');
15
+ }
16
+ }
17
+
@@ -0,0 +1,51 @@
1
+ import { ConsoleLogger, Injectable, LogLevel } from '@nestjs/common';
2
+ import { LoggerConfigService } from './logger-config.service';
3
+ import type { LoggerLevel } from './logger-env.schema';
4
+
5
+ interface HttpLogEntry {
6
+ method: string;
7
+ path: string;
8
+ statusCode: number;
9
+ durationMs: number;
10
+ requestId?: string;
11
+ ip?: string;
12
+ }
13
+
14
+ function resolveLogLevels(level: LoggerLevel): LogLevel[] {
15
+ switch (level) {
16
+ case 'error':
17
+ return ['error'];
18
+ case 'warn':
19
+ return ['error', 'warn'];
20
+ case 'log':
21
+ return ['error', 'warn', 'log'];
22
+ case 'debug':
23
+ return ['error', 'warn', 'log', 'debug'];
24
+ case 'verbose':
25
+ return ['error', 'warn', 'log', 'debug', 'verbose'];
26
+ default:
27
+ return ['error', 'warn', 'log'];
28
+ }
29
+ }
30
+
31
+ @Injectable()
32
+ export class ForgeonLoggerService extends ConsoleLogger {
33
+ constructor(private readonly loggerConfig: LoggerConfigService) {
34
+ super('ForgeonApi');
35
+ this.setLogLevels(resolveLogLevels(this.loggerConfig.level));
36
+ }
37
+
38
+ logHttpRequest(entry: HttpLogEntry): void {
39
+ if (!this.loggerConfig.httpEnabled) {
40
+ return;
41
+ }
42
+
43
+ this.log(
44
+ JSON.stringify({
45
+ event: 'http.request',
46
+ ...entry,
47
+ }),
48
+ );
49
+ }
50
+ }
51
+
@@ -0,0 +1,90 @@
1
+ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
2
+ import { Observable, tap } from 'rxjs';
3
+ import { ForgeonLoggerService } from './forgeon-logger.service';
4
+ import { LoggerConfigService } from './logger-config.service';
5
+
6
+ type HeaderValue = string | string[] | undefined;
7
+ type HeadersRecord = Record<string, HeaderValue>;
8
+
9
+ interface RequestLike {
10
+ method?: string;
11
+ originalUrl?: string;
12
+ url?: string;
13
+ ip?: string;
14
+ requestId?: string;
15
+ headers?: HeadersRecord;
16
+ }
17
+
18
+ interface ResponseLike {
19
+ statusCode?: number;
20
+ }
21
+
22
+ @Injectable()
23
+ export class ForgeonHttpLoggingInterceptor implements NestInterceptor {
24
+ constructor(
25
+ private readonly logger: ForgeonLoggerService,
26
+ private readonly loggerConfig: LoggerConfigService,
27
+ ) {}
28
+
29
+ intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
30
+ if (context.getType<'http'>() !== 'http') {
31
+ return next.handle();
32
+ }
33
+
34
+ if (!this.loggerConfig.httpEnabled) {
35
+ return next.handle();
36
+ }
37
+
38
+ const http = context.switchToHttp();
39
+ const request = http.getRequest<RequestLike>();
40
+ const response = http.getResponse<ResponseLike>();
41
+
42
+ const method = request.method ?? 'UNKNOWN';
43
+ const path = request.originalUrl ?? request.url ?? '/';
44
+ const ip = request.ip;
45
+ const requestId =
46
+ request.requestId ?? this.readHeader(request.headers, this.loggerConfig.requestIdHeader);
47
+ const startedAt = Date.now();
48
+
49
+ return next.handle().pipe(
50
+ tap({
51
+ next: () => {
52
+ this.logger.logHttpRequest({
53
+ method,
54
+ path,
55
+ statusCode: response.statusCode ?? 200,
56
+ durationMs: Date.now() - startedAt,
57
+ requestId,
58
+ ip,
59
+ });
60
+ },
61
+ error: () => {
62
+ this.logger.logHttpRequest({
63
+ method,
64
+ path,
65
+ statusCode: response.statusCode ?? 500,
66
+ durationMs: Date.now() - startedAt,
67
+ requestId,
68
+ ip,
69
+ });
70
+ },
71
+ }),
72
+ );
73
+ }
74
+
75
+ private readHeader(headers: HeadersRecord | undefined, name: string): string | undefined {
76
+ if (!headers) {
77
+ return undefined;
78
+ }
79
+
80
+ const value = headers[name.toLowerCase()];
81
+ if (typeof value === 'string' && value.trim().length > 0) {
82
+ return value;
83
+ }
84
+ if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim().length > 0) {
85
+ return value[0];
86
+ }
87
+ return undefined;
88
+ }
89
+ }
90
+
@@ -0,0 +1,9 @@
1
+ export * from './logger-env.schema';
2
+ export * from './logger-config.loader';
3
+ export * from './logger-config.service';
4
+ export * from './logger-config.module';
5
+ export * from './forgeon-logger.service';
6
+ export * from './http-logging.interceptor';
7
+ export * from './request-id.middleware';
8
+ export * from './forgeon-logger.module';
9
+
@@ -0,0 +1,20 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { LoggerLevel, parseLoggerEnv } from './logger-env.schema';
3
+
4
+ export const LOGGER_CONFIG_NAMESPACE = 'logger';
5
+
6
+ export interface LoggerConfigValues {
7
+ level: LoggerLevel;
8
+ httpEnabled: boolean;
9
+ requestIdHeader: string;
10
+ }
11
+
12
+ export const loggerConfig = registerAs(LOGGER_CONFIG_NAMESPACE, (): LoggerConfigValues => {
13
+ const env = parseLoggerEnv(process.env);
14
+ return {
15
+ level: env.LOGGER_LEVEL,
16
+ httpEnabled: env.LOGGER_HTTP_ENABLED,
17
+ requestIdHeader: env.LOGGER_REQUEST_ID_HEADER.toLowerCase(),
18
+ };
19
+ });
20
+
@@ -0,0 +1,11 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { LoggerConfigService } from './logger-config.service';
4
+
5
+ @Module({
6
+ imports: [ConfigModule],
7
+ providers: [LoggerConfigService],
8
+ exports: [LoggerConfigService],
9
+ })
10
+ export class LoggerConfigModule {}
11
+
@@ -0,0 +1,23 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { LOGGER_CONFIG_NAMESPACE, LoggerConfigValues } from './logger-config.loader';
4
+
5
+ @Injectable()
6
+ export class LoggerConfigService {
7
+ constructor(private readonly configService: ConfigService) {}
8
+
9
+ get level(): LoggerConfigValues['level'] {
10
+ return this.configService.getOrThrow<LoggerConfigValues['level']>(
11
+ `${LOGGER_CONFIG_NAMESPACE}.level`,
12
+ );
13
+ }
14
+
15
+ get httpEnabled(): boolean {
16
+ return this.configService.getOrThrow<boolean>(`${LOGGER_CONFIG_NAMESPACE}.httpEnabled`);
17
+ }
18
+
19
+ get requestIdHeader(): string {
20
+ return this.configService.getOrThrow<string>(`${LOGGER_CONFIG_NAMESPACE}.requestIdHeader`);
21
+ }
22
+ }
23
+
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+
3
+ const loggerLevelSchema = z.enum(['error', 'warn', 'log', 'debug', 'verbose']);
4
+
5
+ export const loggerEnvSchema = z
6
+ .object({
7
+ LOGGER_LEVEL: loggerLevelSchema.default('log'),
8
+ LOGGER_HTTP_ENABLED: z.coerce.boolean().default(true),
9
+ LOGGER_REQUEST_ID_HEADER: z.string().trim().min(1).default('x-request-id'),
10
+ })
11
+ .passthrough();
12
+
13
+ export type LoggerLevel = z.infer<typeof loggerLevelSchema>;
14
+ export type LoggerEnv = z.infer<typeof loggerEnvSchema>;
15
+
16
+ export function parseLoggerEnv(input: Record<string, unknown>): LoggerEnv {
17
+ return loggerEnvSchema.parse(input);
18
+ }
19
+
@@ -0,0 +1,52 @@
1
+ import { Injectable, NestMiddleware } from '@nestjs/common';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { LoggerConfigService } from './logger-config.service';
4
+
5
+ type HeaderValue = string | string[] | undefined;
6
+ type HeadersRecord = Record<string, HeaderValue>;
7
+
8
+ interface RequestLike {
9
+ headers: HeadersRecord;
10
+ requestId?: string;
11
+ }
12
+
13
+ interface ResponseLike {
14
+ setHeader?: (name: string, value: string) => void;
15
+ }
16
+
17
+ @Injectable()
18
+ export class RequestIdMiddleware implements NestMiddleware {
19
+ constructor(private readonly loggerConfig: LoggerConfigService) {}
20
+
21
+ use(req: RequestLike, res: ResponseLike, next: () => void): void {
22
+ const headerName = this.loggerConfig.requestIdHeader;
23
+ const requestId = this.readHeader(req.headers, headerName) ?? randomUUID();
24
+
25
+ req.requestId = requestId;
26
+ req.headers[headerName] = requestId;
27
+ if (typeof res.setHeader === 'function') {
28
+ res.setHeader(headerName, requestId);
29
+ }
30
+
31
+ next();
32
+ }
33
+
34
+ private readHeader(headers: HeadersRecord | undefined, name: string): string | undefined {
35
+ if (!headers) {
36
+ return undefined;
37
+ }
38
+
39
+ const normalized = name.toLowerCase();
40
+ const value = headers[normalized];
41
+ if (typeof value === 'string' && value.trim().length > 0) {
42
+ return value;
43
+ }
44
+
45
+ if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim().length > 0) {
46
+ return value[0];
47
+ }
48
+
49
+ return undefined;
50
+ }
51
+ }
52
+
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../../../../tsconfig.base.node.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
10
+