From c295acf32475c6bdcfc743d2d0f96254ad9d7f81 Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 7 May 2026 18:23:04 +0300 Subject: [PATCH 1/3] feat(config): implement strict environment variables validation --- .env.example | 7 +-- .prettierignore | 5 +- Dockerfile.prod | 20 ++++-- next-env.d.ts | 2 +- next.config.ts | 2 + src/shared/config/env.ts | 129 ++++++++++++++++++++++++++++++++++++++ src/shared/types/env.d.ts | 9 +++ tsconfig.json | 1 + 8 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 src/shared/config/env.ts create mode 100644 src/shared/types/env.d.ts diff --git a/.env.example b/.env.example index dcc9002..5f131bf 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/api/ +NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/v1/ PORT=3000 # Next App FRONTEND Instrumentation NEXT_PUBLIC_FARO_URL=http://localhost:12347/collect @@ -16,8 +16,3 @@ OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_SERVICE_NAME=next-backend ## Customize resource attributes, namespace is a recommended attribute OTEL_RESOURCE_ATTRIBUTES=service.namespace=nextjs-example - -# OTel collector -GRAFANA_CLOUD_USERNAME= -GRAFANA_CLOUD_API_KEY= -GRAFANA_CLOUD_ENDPOINT= \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index cf0a87c..74537bb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,7 @@ node_modules coverage pnpm-lock.yaml *.tsbuildinfo -*.log \ No newline at end of file +*.log +Dockefile* +env*. +.github/* \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod index b878708..3f949e3 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -14,9 +14,18 @@ COPY package.json ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile FROM base AS builder + ARG NEXT_PUBLIC_API_BASE_URL +ARG NEXT_PUBLIC_FARO_URL +ARG NEXT_PUBLIC_FARO_APP_VERSION +ARG NEXT_PUBLIC_APP_ENV ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ + NEXT_PUBLIC_FARO_URL=$NEXT_PUBLIC_FARO_URL \ + NEXT_PUBLIC_FARO_APP_NAME="frontend" \ + NEXT_PUBLIC_FARO_APP_NAMESPACE="frontend" \ + NEXT_PUBLIC_FARO_APP_VERSION=$NEXT_PUBLIC_FARO_APP_VERSION \ + NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV \ NEXT_TELEMETRY_DISABLED=1 COPY --from=deps /app/node_modules ./node_modules @@ -27,11 +36,14 @@ RUN --mount=type=cache,target=/app/.next/cache pnpm run build FROM node:20-alpine AS runner WORKDIR /app -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME="0.0.0.0" -ENV PORT=${PORT:-3001} -ENV HOSTNAME="0.0.0.0" +ENV OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ + OTEL_SERVICE_NAME="frontend-ssr" \ + OTEL_RESOURCE_ATTRIBUTES="service.namespace=frontend" RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 frontend diff --git a/next-env.d.ts b/next-env.d.ts index cdb6b7b..0c7fad7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import './.next/dev/types/routes.d.ts'; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index e8b17a6..956d8d3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,3 +1,4 @@ +import 'shared/config/env'; import type { NextConfig } from 'next'; const nextConfig: NextConfig = { @@ -5,6 +6,7 @@ const nextConfig: NextConfig = { turbopack: { root: __dirname, }, + output: 'standalone', }; diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts new file mode 100644 index 0000000..4295611 --- /dev/null +++ b/src/shared/config/env.ts @@ -0,0 +1,129 @@ +import { z } from 'zod/v4'; + +const envSchema = z.object({ + NODE_ENV: z + .enum(['development', 'production', 'test'], { + error: () => ({ message: 'NODE_ENV должен быть: development, production или test' }), + }) + .default('development'), + PORT: z.coerce + .number() + .min(1000, 'Порт не может быть ниже 1000') + .max(65535, 'Неверный номер порта') + .default(3000), + NEXT_PUBLIC_API_BASE_URL: z + .string({ + error: 'API Base URL обязателен', + }) + .url('NEXT_PUBLIC_API_BASE_URL должен быть валидным URL'), + NEXT_PUBLIC_FARO_URL: z + .string({ + error: 'URL для Faro (Alloy) обязателен', + }) + .url('NEXT_PUBLIC_FARO_URL должен быть валидным URL (например, http://alloy:12347/collect)'), + NEXT_PUBLIC_FARO_APP_NAME: z + .string({ + error: 'Имя приложения для Faro обязательно', + }) + .min(1, 'Имя приложения не может быть пустым'), + NEXT_PUBLIC_FARO_APP_NAMESPACE: z + .string({ + error: 'Namespace приложения обязателен', + }) + .min(1, 'Namespace не может быть пустым'), + NEXT_PUBLIC_FARO_APP_VERSION: z.string().default('1.0.0'), + NEXT_PUBLIC_APP_ENV: z + .string({ + error: 'Окружение (APP_ENV) обязательно', + }) + .min(1, 'Окружение не может быть пустым'), + OTEL_EXPORTER_OTLP_ENDPOINT: z + .string({ + error: 'Эндпоинт OTLP обязателен', + }) + .url('OTEL_EXPORTER_OTLP_ENDPOINT должен быть валидным URL (например, http://alloy:4318)'), + OTEL_EXPORTER_OTLP_PROTOCOL: z.enum(['http/protobuf', 'http/json', 'grpc'], { + error: () => ({ message: 'Протокол должен быть http/protobuf, http/json или grpc' }), + }), + OTEL_SERVICE_NAME: z + .string({ + error: 'Имя OTEL сервиса обязательно', + }) + .min(1, 'Имя сервиса не может быть пустым'), + OTEL_RESOURCE_ATTRIBUTES: z + .string({ + error: 'Атрибуты ресурсов (Resource Attributes) обязательны', + }) + .includes('service.namespace=', { message: 'Атрибуты должны содержать service.namespace' }), +}); + +const _env = envSchema.safeParse(process.env); + +const isServer = typeof window === 'undefined'; +if (!_env.success) { + if (isServer) { + console.error('\n\x1b[1;31m[!] CONFIGURATION_ERROR\x1b[0m'); + + _env.error.issues.forEach((issue) => { + const path = issue.path.join('.') || 'root'; + console.error(` \x1b[31m> \x1b[0m \x1b[1m${path}\x1b[0m: \x1b[31m${issue.message}\x1b[0m`); + }); + + console.error('\n\x1b[33mHint:\x1b[0m Check your .env or Docker build-args\n'); + process.exit(1); + } else { + const styles: Record = { + badge: + 'background: #cc0000; color: white; font-family: monospace; font-weight: bold; padding: 2px 4px; border-radius: 2px;', + text: 'color: #ff4444; font-family: monospace; font-weight: bold;', + }; + + console.group('%c ELIFECYCLE %c Command failed with exit code 1.', styles.badge, styles.text); + + _env.error.issues.forEach((issue) => { + const path = issue.path.join('.') || 'root'; + console.error( + `%cerror %c${path}: %c${issue.message}`, + 'color: #ff4444; font-weight: bold;', + 'color: white; font-weight: bold;', + 'color: #aaa;' + ); + }); + + console.groupEnd(); + + throw new Error('Environment validation failed'); + } +} else { + if (isServer) { + console.log( + '\n\x1b[42m\x1b[30m READY \x1b[0m \x1b[32mEnvironment variables validated successfully.\x1b[0m' + ); + + const entries = Object.entries(_env.data); + const publicEnvs = entries.filter(([key]) => key.startsWith('NEXT_PUBLIC_')); + const privateEnvs = entries.filter(([key]) => !key.startsWith('NEXT_PUBLIC_')); + + if (publicEnvs.length > 0) { + console.log('\x1b[36m ○ Client (Public):\x1b[0m'); + publicEnvs.forEach(([key, value]) => { + console.log( + `\x1b[32m > \x1b[0m \x1b[90m${key.padEnd(30)}\x1b[0m : \x1b[1m${value}\x1b[0m` + ); + }); + } + + if (privateEnvs.length > 0) { + console.log('\x1b[35m ○ Node (System):\x1b[0m'); + privateEnvs.forEach(([key, value]) => { + console.log( + `\x1b[32m > \x1b[0m \x1b[90m${key.padEnd(30)}\x1b[0m : \x1b[1m${value}\x1b[0m` + ); + }); + } + console.log(''); + } +} + +export type Env = z.infer; +export const env = _env.data; diff --git a/src/shared/types/env.d.ts b/src/shared/types/env.d.ts new file mode 100644 index 0000000..12bcfd4 --- /dev/null +++ b/src/shared/types/env.d.ts @@ -0,0 +1,9 @@ +import { Env } from 'shared/config/env'; + +declare global { + namespace NodeJS { + interface ProcessEnv extends Env {} + } +} + +export {}; diff --git a/tsconfig.json b/tsconfig.json index cdef9d1..a4b9d13 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", + "src/shared/types/*.d.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts" From 8f72df3baf120ccabc0fbd887827b84b234a3206 Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 7 May 2026 19:27:48 +0300 Subject: [PATCH 2/3] resolve: rule error and add env to workflow --- .github/workflows/build.yml | 5 ++++- next-env.d.ts | 2 +- src/shared/config/env.d.ts | 10 ++++++++++ src/shared/types/env.d.ts | 9 --------- tsconfig.json | 2 +- 5 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 src/shared/config/env.d.ts delete mode 100644 src/shared/types/env.d.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55c290a..33ea89e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build and Push on: push: - branches: [dev, main, feat/**] + branches: [ dev, main, feat/** ] env: REGISTRY: ghcr.io @@ -61,6 +61,9 @@ jobs: file: ./Dockerfile.prod build-args: | NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL }} + NEXT_PUBLIC_FARO_URL=${{ vars.NEXT_PUBLIC_FARO_URL }} + NEXT_PUBLIC_FARO_APP_VERSION=${{ vars.NEXT_PUBLIC_FARO_APP_VERSION }} + NEXT_PUBLIC_APP_ENV=${{ vars.NEXT_PUBLIC_APP_ENV }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/next-env.d.ts b/next-env.d.ts index 0c7fad7..cdb6b7b 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/dev/types/routes.d.ts"; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/shared/config/env.d.ts b/src/shared/config/env.d.ts new file mode 100644 index 0000000..2d44ddb --- /dev/null +++ b/src/shared/config/env.d.ts @@ -0,0 +1,10 @@ +import type { Env } from './env'; + +declare global { + namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface ProcessEnv extends Env {} + } +} + +export {}; diff --git a/src/shared/types/env.d.ts b/src/shared/types/env.d.ts deleted file mode 100644 index 12bcfd4..0000000 --- a/src/shared/types/env.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Env } from 'shared/config/env'; - -declare global { - namespace NodeJS { - interface ProcessEnv extends Env {} - } -} - -export {}; diff --git a/tsconfig.json b/tsconfig.json index a4b9d13..7422fbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,7 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - "src/shared/types/*.d.ts", + "src/shared/model/*.d.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts" From 2cbf229b679f0d683a5cdbfb01fb896f674b4093 Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 7 May 2026 19:39:06 +0300 Subject: [PATCH 3/3] chore: add special env to CI build --- .env.example | 3 +++ .github/workflows/ci.yml | 12 ++++++++---- .prettierignore | 30 +++++++++++++++++++++++++----- src/shared/config/env.ts | 4 +++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 5f131bf..f10f261 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,6 @@ OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_SERVICE_NAME=next-backend ## Customize resource attributes, namespace is a recommended attribute OTEL_RESOURCE_ATTRIBUTES=service.namespace=nextjs-example + +# Option special per CI workflow +SKIP_ENV_VALIDATION=true/false \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 669a825..db3318f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI workflow on: pull_request: - branches: ['dev', 'main'] + branches: [ "dev", "main" ] paths-ignore: - - '*.md' + - "*.md" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -25,7 +25,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 - cache: 'pnpm' + cache: "pnpm" - name: Install deps run: pnpm install --frozen-lockfile @@ -44,9 +44,13 @@ jobs: - name: Unit tests run: pnpm test:ci + env: + NODE_ENV: test - name: Build run: pnpm build - + env: + SKIP_ENV_VALIDATION: true + NODE_ENV: production # - name: Build Storybook # run: pnpm build-storybook diff --git a/.prettierignore b/.prettierignore index 74537bb..76b8561 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,9 +1,29 @@ +# Сборка и зависимости node_modules .next -coverage -pnpm-lock.yaml +out +build +dist + +# Статика и кэш +public +.pnpm-home +.pnpm-store *.tsbuildinfo + +# Логи и отчеты +coverage *.log -Dockefile* -env*. -.github/* \ No newline at end of file +.npm + +# Конфиги и инфраструктура (обычно их не форматируют так же, как код) +Dockerfile* +pnpm-lock.yaml +.dockerignore +.gitignore + +# Секреты +.env* + +.prettierignore +.github/ \ No newline at end of file diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 4295611..e2074c7 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -1,5 +1,7 @@ import { z } from 'zod/v4'; +const isTest = process.env.NODE_ENV === 'test' || process.env.SKIP_ENV_VALIDATION === 'true'; + const envSchema = z.object({ NODE_ENV: z .enum(['development', 'production', 'test'], { @@ -57,7 +59,7 @@ const envSchema = z.object({ .includes('service.namespace=', { message: 'Атрибуты должны содержать service.namespace' }), }); -const _env = envSchema.safeParse(process.env); +const _env = isTest ? envSchema.partial().safeParse(process.env) : envSchema.safeParse(process.env); const isServer = typeof window === 'undefined'; if (!_env.success) {