create-forgeon 0.1.27 → 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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/modules/executor.mjs +2 -0
  3. package/src/modules/executor.test.mjs +87 -4
  4. package/src/modules/logger.mjs +241 -0
  5. package/src/modules/registry.mjs +8 -0
  6. package/templates/base/apps/api/src/common/dto/echo-query.dto.ts +4 -4
  7. package/templates/base/apps/api/src/health/health.controller.ts +5 -5
  8. package/templates/base/docs/AI/MODULE_SPEC.md +2 -0
  9. package/templates/base/docs/AI/ROADMAP.md +171 -0
  10. package/templates/base/docs/AI/TASKS.md +1 -0
  11. package/templates/base/docs/README.md +1 -0
  12. package/templates/base/packages/core/src/errors/core-exception.filter.ts +18 -6
  13. package/templates/base/resources/i18n/en/common.json +39 -11
  14. package/templates/base/resources/i18n/en/errors.json +31 -3
  15. package/templates/base/resources/i18n/en/meta.json +8 -0
  16. package/templates/base/resources/i18n/en/notifications.json +21 -0
  17. package/templates/base/resources/i18n/en/ui.json +31 -0
  18. package/templates/base/resources/i18n/en/validation.json +30 -1
  19. package/templates/base/scripts/i18n-add.mjs +6 -3
  20. package/templates/docs-fragments/README/40_i18n.md +1 -0
  21. package/templates/module-fragments/i18n/10_overview.md +1 -0
  22. package/templates/module-fragments/logger/00_title.md +6 -0
  23. package/templates/module-fragments/logger/10_overview.md +10 -0
  24. package/templates/module-fragments/logger/20_scope.md +11 -0
  25. package/templates/module-fragments/logger/90_status_implemented.md +4 -0
  26. package/templates/module-presets/i18n/apps/web/src/App.tsx +7 -7
  27. package/templates/module-presets/i18n/apps/web/src/i18n.ts +6 -0
  28. package/templates/module-presets/i18n/packages/i18n-contracts/src/generated-keys.d.ts +97 -12
  29. package/templates/module-presets/i18n/packages/i18n-contracts/src/generated.ts +8 -1
  30. package/templates/module-presets/logger/packages/logger/package.json +21 -0
  31. package/templates/module-presets/logger/packages/logger/src/forgeon-logger.module.ts +17 -0
  32. package/templates/module-presets/logger/packages/logger/src/forgeon-logger.service.ts +51 -0
  33. package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +90 -0
  34. package/templates/module-presets/logger/packages/logger/src/index.ts +9 -0
  35. package/templates/module-presets/logger/packages/logger/src/logger-config.loader.ts +20 -0
  36. package/templates/module-presets/logger/packages/logger/src/logger-config.module.ts +11 -0
  37. package/templates/module-presets/logger/packages/logger/src/logger-config.service.ts +23 -0
  38. package/templates/module-presets/logger/packages/logger/src/logger-env.schema.ts +19 -0
  39. package/templates/module-presets/logger/packages/logger/src/request-id.middleware.ts +52 -0
  40. package/templates/module-presets/logger/packages/logger/tsconfig.json +10 -0
@@ -1,14 +1,42 @@
1
1
  {
2
- "ok": "OK",
3
- "checkApiHealth": "Check API health",
4
- "checkErrorEnvelope": "Check error envelope",
5
- "checkValidation": "Check validation (expect 400)",
6
- "checkDatabase": "Check database (create user)",
7
- "language": "Language",
8
- "probes": {
9
- "error": "Error envelope probe"
10
- },
11
- "languages": {
12
- "english": "English"
2
+ "actions": {
3
+ "ok": "OK",
4
+ "cancel": "Cancel",
5
+ "save": "Save",
6
+ "delete": "Delete",
7
+ "edit": "Edit",
8
+ "create": "Create",
9
+ "update": "Update",
10
+ "confirm": "Confirm",
11
+ "retry": "Retry"
12
+ },
13
+ "nav": {
14
+ "back": "Back",
15
+ "next": "Next",
16
+ "close": "Close"
17
+ },
18
+ "state": {
19
+ "loading": "Loading...",
20
+ "empty": "No data",
21
+ "selected": "Selected"
22
+ },
23
+ "status": {
24
+ "active": "Active",
25
+ "inactive": "Inactive",
26
+ "pending": "Pending",
27
+ "disabled": "Disabled",
28
+ "archived": "Archived"
29
+ },
30
+ "time": {
31
+ "today": "Today",
32
+ "yesterday": "Yesterday",
33
+ "tomorrow": "Tomorrow",
34
+ "now": "Now"
35
+ },
36
+ "misc": {
37
+ "yes": "Yes",
38
+ "no": "No",
39
+ "on": "On",
40
+ "off": "Off"
13
41
  }
14
42
  }
@@ -1,5 +1,33 @@
1
1
  {
2
- "accessDenied": "Access denied",
3
- "notFound": "Resource not found",
4
- "emailAlreadyExists": "Email already exists"
2
+ "http": {
3
+ "BAD_REQUEST": "Bad request",
4
+ "NOT_FOUND": "Resource not found",
5
+ "UNAUTHORIZED": "Unauthorized",
6
+ "FORBIDDEN": "Access denied",
7
+ "CONFLICT": "Conflict",
8
+ "TOO_MANY_REQUESTS": "Too many requests",
9
+ "METHOD_NOT_ALLOWED": "Method not allowed",
10
+ "UNPROCESSABLE_ENTITY": "Unprocessable entity",
11
+ "INTERNAL_ERROR": "Internal server error",
12
+ "SERVICE_UNAVAILABLE": "Service unavailable",
13
+ "BAD_GATEWAY": "Bad gateway",
14
+ "GATEWAY_TIMEOUT": "Gateway timeout"
15
+ },
16
+ "network": {
17
+ "NETWORK_ERROR": "Network error",
18
+ "TIMEOUT": "Request timeout",
19
+ "OFFLINE": "You appear to be offline"
20
+ },
21
+ "auth": {
22
+ "AUTH_INVALID_CREDENTIALS": "Invalid credentials",
23
+ "AUTH_TOKEN_EXPIRED": "Session expired"
24
+ },
25
+ "validation": {
26
+ "VALIDATION_ERROR": "Validation error"
27
+ },
28
+ "files": {
29
+ "UPLOAD_FILE_TOO_LARGE": "File is too large",
30
+ "UPLOAD_INVALID_TYPE": "Invalid file type",
31
+ "UPLOAD_QUOTA_EXCEEDED": "Upload quota exceeded"
32
+ }
5
33
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "pages": {
3
+ "home": {
4
+ "title": "Forgeon Fullstack Scaffold",
5
+ "description": "Canonical fullstack starter with NestJS, React, and Prisma."
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "success": {
3
+ "saved": "Saved successfully",
4
+ "updated": "Updated successfully",
5
+ "deleted": "Deleted successfully",
6
+ "copied": "Copied successfully"
7
+ },
8
+ "error": {
9
+ "saveFailed": "Failed to save",
10
+ "updateFailed": "Failed to update",
11
+ "deleteFailed": "Failed to delete"
12
+ },
13
+ "info": {
14
+ "changesDiscarded": "Changes were discarded",
15
+ "sessionExpired": "Your session has expired"
16
+ },
17
+ "progress": {
18
+ "uploading": "Uploading...",
19
+ "processing": "Processing..."
20
+ }
21
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "labels": {
3
+ "name": "Name",
4
+ "email": "Email",
5
+ "password": "Password",
6
+ "search": "Search",
7
+ "language": "Language"
8
+ },
9
+ "placeholders": {
10
+ "email": "Enter email",
11
+ "search": "Search...",
12
+ "password": "Enter password"
13
+ },
14
+ "titles": {
15
+ "home": "Home",
16
+ "dashboard": "Dashboard",
17
+ "settings": "Settings"
18
+ },
19
+ "messages": {
20
+ "emptyState": "Nothing to display yet",
21
+ "noResults": "No results found"
22
+ },
23
+ "table": {
24
+ "rowsPerPage": "Rows per page",
25
+ "noData": "No data"
26
+ },
27
+ "modal": {
28
+ "confirmTitle": "Please confirm",
29
+ "confirmText": "Are you sure you want to continue?"
30
+ }
31
+ }
@@ -1,3 +1,32 @@
1
1
  {
2
- "required": "Field is required"
2
+ "generic": {
3
+ "required": "Field is required",
4
+ "invalid": "Invalid value",
5
+ "outOfRange": "Value is out of range"
6
+ },
7
+ "string": {
8
+ "minLength": "Value is too short",
9
+ "maxLength": "Value is too long",
10
+ "pattern": "Value does not match required format"
11
+ },
12
+ "number": {
13
+ "min": "Value is too small",
14
+ "max": "Value is too large",
15
+ "int": "Value must be an integer"
16
+ },
17
+ "format": {
18
+ "email": "Invalid email format",
19
+ "phone": "Invalid phone format",
20
+ "url": "Invalid URL format",
21
+ "uuid": "Invalid UUID format"
22
+ },
23
+ "date": {
24
+ "minDate": "Date is too early",
25
+ "maxDate": "Date is too late",
26
+ "invalidDate": "Invalid date"
27
+ },
28
+ "file": {
29
+ "fileTooLarge": "File is too large",
30
+ "invalidType": "Invalid file type"
31
+ }
3
32
  }
@@ -84,9 +84,13 @@ async function listJsonFiles(folderPath) {
84
84
  }
85
85
 
86
86
  function runSyncCommand(cwd) {
87
- const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
87
+ const command = 'pnpm i18n:sync';
88
88
  return new Promise((resolve, reject) => {
89
- const child = spawn(command, ['i18n:sync'], { cwd, stdio: 'inherit' });
89
+ const child = spawn(command, {
90
+ cwd,
91
+ stdio: 'inherit',
92
+ shell: true,
93
+ });
90
94
  child.on('error', (error) => reject(error));
91
95
  child.on('exit', (code) => {
92
96
  if (code === 0) {
@@ -185,4 +189,3 @@ main().catch((error) => {
185
189
  printUsage();
186
190
  process.exitCode = 1;
187
191
  });
188
-
@@ -6,6 +6,7 @@ Environment keys:
6
6
 
7
7
  Resources location: `resources/i18n`.
8
8
  These dictionaries are shared by backend (`nestjs-i18n`) and frontend (`react-i18next`).
9
+ Default namespaces: `common`, `errors`, `validation`, `ui`, `notifications`, `meta`.
9
10
 
10
11
  Packages:
11
12
  - `@forgeon/i18n`
@@ -8,6 +8,7 @@ Included parts:
8
8
  - `@forgeon/i18n-web` (React-side locale helpers)
9
9
  - `react-i18next` integration for frontend translations
10
10
  - shared dictionaries in `resources/i18n/*` (`en` by default) used by both API and web
11
+ - default namespaces: `common`, `errors`, `validation`, `ui`, `notifications`, `meta`
11
12
 
12
13
  Utility commands:
13
14
  - `pnpm i18n:sync` - regenerate `I18N_LOCALES` and `I18N_NAMESPACES` from `resources/i18n`.
@@ -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
+
@@ -11,7 +11,7 @@ type ProbeResult = {
11
11
  };
12
12
 
13
13
  export default function App() {
14
- const { t } = useTranslation(['common']);
14
+ const { t } = useTranslation(['ui']);
15
15
  const { I18N_LOCALES, getInitialLocale, persistLocale, toLangQuery } = i18nWeb;
16
16
  const [locale, setLocale] = useState<I18nLocale>(getInitialLocale);
17
17
  const [healthResult, setHealthResult] = useState<ProbeResult | null>(null);
@@ -73,7 +73,7 @@ export default function App() {
73
73
  <main className="page">
74
74
  <h1>Forgeon Fullstack Scaffold</h1>
75
75
  <p>Default frontend preset: React + Vite + TypeScript.</p>
76
- <label htmlFor="language">{t('common:language')}:</label>
76
+ <label htmlFor="language">{t('ui:labels.language')}:</label>
77
77
  <select
78
78
  id="language"
79
79
  value={locale}
@@ -81,20 +81,20 @@ export default function App() {
81
81
  >
82
82
  {I18N_LOCALES.map((item) => (
83
83
  <option key={item} value={item}>
84
- {t(`common:languages.${item}`, { defaultValue: item })}
84
+ {item}
85
85
  </option>
86
86
  ))}
87
87
  </select>
88
88
  <div className="actions">
89
- <button onClick={() => runProbe(setHealthResult, '/health')}>{t('common:checkApiHealth')}</button>
89
+ <button onClick={() => runProbe(setHealthResult, '/health')}>Check API health</button>
90
90
  <button onClick={() => runProbe(setErrorProbeResult, '/health/error')}>
91
- {t('common:checkErrorEnvelope')}
91
+ Check error envelope
92
92
  </button>
93
93
  <button onClick={() => runProbe(setValidationProbeResult, '/health/validation')}>
94
- {t('common:checkValidation')}
94
+ Check validation (expect 400)
95
95
  </button>
96
96
  <button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>
97
- {t('common:checkDatabase')}
97
+ Check database (create user)
98
98
  </button>
99
99
  </div>
100
100
  {renderResult('Health response', healthResult)}
@@ -3,12 +3,18 @@ import { initReactI18next } from 'react-i18next';
3
3
  import { getInitialLocale, I18N_LOCALES, type I18nLocale } from '@forgeon/i18n-web';
4
4
  import enCommon from '../../../resources/i18n/en/common.json';
5
5
  import enErrors from '../../../resources/i18n/en/errors.json';
6
+ import enMeta from '../../../resources/i18n/en/meta.json';
7
+ import enNotifications from '../../../resources/i18n/en/notifications.json';
8
+ import enUi from '../../../resources/i18n/en/ui.json';
6
9
  import enValidation from '../../../resources/i18n/en/validation.json';
7
10
 
8
11
  const resources = {
9
12
  en: {
10
13
  common: enCommon,
11
14
  errors: enErrors,
15
+ meta: enMeta,
16
+ notifications: enNotifications,
17
+ ui: enUi,
12
18
  validation: enValidation,
13
19
  },
14
20
  } as const;
@@ -1,15 +1,100 @@
1
1
  /* AUTO-GENERATED BY `pnpm i18n:types`. DO NOT EDIT MANUALLY. */
2
2
 
3
3
  export type I18nTranslationKey =
4
- | "common.checkApiHealth"
5
- | "common.checkDatabase"
6
- | "common.checkErrorEnvelope"
7
- | "common.checkValidation"
8
- | "common.language"
9
- | "common.languages.english"
10
- | "common.ok"
11
- | "common.probes.error"
12
- | "errors.accessDenied"
13
- | "errors.emailAlreadyExists"
14
- | "errors.notFound"
15
- | "validation.required";
4
+ | "common.actions.ok"
5
+ | "common.actions.cancel"
6
+ | "common.actions.save"
7
+ | "common.actions.delete"
8
+ | "common.actions.edit"
9
+ | "common.actions.create"
10
+ | "common.actions.update"
11
+ | "common.actions.confirm"
12
+ | "common.actions.retry"
13
+ | "common.nav.back"
14
+ | "common.nav.next"
15
+ | "common.nav.close"
16
+ | "common.state.loading"
17
+ | "common.state.empty"
18
+ | "common.state.selected"
19
+ | "common.status.active"
20
+ | "common.status.inactive"
21
+ | "common.status.pending"
22
+ | "common.status.disabled"
23
+ | "common.status.archived"
24
+ | "common.time.today"
25
+ | "common.time.yesterday"
26
+ | "common.time.tomorrow"
27
+ | "common.time.now"
28
+ | "common.misc.yes"
29
+ | "common.misc.no"
30
+ | "common.misc.on"
31
+ | "common.misc.off"
32
+ | "errors.http.NOT_FOUND"
33
+ | "errors.http.BAD_REQUEST"
34
+ | "errors.http.UNAUTHORIZED"
35
+ | "errors.http.FORBIDDEN"
36
+ | "errors.http.CONFLICT"
37
+ | "errors.http.TOO_MANY_REQUESTS"
38
+ | "errors.http.METHOD_NOT_ALLOWED"
39
+ | "errors.http.UNPROCESSABLE_ENTITY"
40
+ | "errors.http.INTERNAL_ERROR"
41
+ | "errors.http.SERVICE_UNAVAILABLE"
42
+ | "errors.http.BAD_GATEWAY"
43
+ | "errors.http.GATEWAY_TIMEOUT"
44
+ | "errors.network.NETWORK_ERROR"
45
+ | "errors.network.TIMEOUT"
46
+ | "errors.network.OFFLINE"
47
+ | "errors.auth.AUTH_INVALID_CREDENTIALS"
48
+ | "errors.auth.AUTH_TOKEN_EXPIRED"
49
+ | "errors.validation.VALIDATION_ERROR"
50
+ | "errors.files.UPLOAD_FILE_TOO_LARGE"
51
+ | "errors.files.UPLOAD_INVALID_TYPE"
52
+ | "errors.files.UPLOAD_QUOTA_EXCEEDED"
53
+ | "meta.pages.home.title"
54
+ | "meta.pages.home.description"
55
+ | "notifications.success.saved"
56
+ | "notifications.success.updated"
57
+ | "notifications.success.deleted"
58
+ | "notifications.success.copied"
59
+ | "notifications.error.saveFailed"
60
+ | "notifications.error.updateFailed"
61
+ | "notifications.error.deleteFailed"
62
+ | "notifications.info.changesDiscarded"
63
+ | "notifications.info.sessionExpired"
64
+ | "notifications.progress.uploading"
65
+ | "notifications.progress.processing"
66
+ | "ui.labels.name"
67
+ | "ui.labels.email"
68
+ | "ui.labels.password"
69
+ | "ui.labels.search"
70
+ | "ui.labels.language"
71
+ | "ui.placeholders.email"
72
+ | "ui.placeholders.search"
73
+ | "ui.placeholders.password"
74
+ | "ui.titles.home"
75
+ | "ui.titles.dashboard"
76
+ | "ui.titles.settings"
77
+ | "ui.messages.emptyState"
78
+ | "ui.messages.noResults"
79
+ | "ui.table.rowsPerPage"
80
+ | "ui.table.noData"
81
+ | "ui.modal.confirmTitle"
82
+ | "ui.modal.confirmText"
83
+ | "validation.generic.required"
84
+ | "validation.generic.invalid"
85
+ | "validation.generic.outOfRange"
86
+ | "validation.string.minLength"
87
+ | "validation.string.maxLength"
88
+ | "validation.string.pattern"
89
+ | "validation.number.min"
90
+ | "validation.number.max"
91
+ | "validation.number.int"
92
+ | "validation.format.email"
93
+ | "validation.format.phone"
94
+ | "validation.format.url"
95
+ | "validation.format.uuid"
96
+ | "validation.date.minDate"
97
+ | "validation.date.maxDate"
98
+ | "validation.date.invalidDate"
99
+ | "validation.file.fileTooLarge"
100
+ | "validation.file.invalidType";
@@ -1,7 +1,14 @@
1
1
  /* AUTO-GENERATED BY `pnpm i18n:sync`. DO NOT EDIT MANUALLY. */
2
2
 
3
3
  export const I18N_LOCALES = ["en"] as const;
4
- export const I18N_NAMESPACES = ["common", "errors", "validation"] as const;
4
+ export const I18N_NAMESPACES = [
5
+ "common",
6
+ "errors",
7
+ "meta",
8
+ "notifications",
9
+ "ui",
10
+ "validation"
11
+ ] as const;
5
12
 
6
13
  export type I18nLocale = (typeof I18N_LOCALES)[number];
7
14
  export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
@@ -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
+