diff --git a/.env.example b/.env.example index dcc9002..f10f261 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 @@ -17,7 +17,5 @@ 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 +# Option special per CI workflow +SKIP_ENV_VALIDATION=true/false \ No newline at end of file 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/.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 cf0a87c..76b8561 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,29 @@ +# Сборка и зависимости node_modules .next +out +build +dist + +# Статика и кэш +public +.pnpm-home +.pnpm-store +*.tsbuildinfo + +# Логи и отчеты coverage +*.log +.npm + +# Конфиги и инфраструктура (обычно их не форматируют так же, как код) +Dockerfile* pnpm-lock.yaml -*.tsbuildinfo -*.log \ No newline at end of file +.dockerignore +.gitignore + +# Секреты +.env* + +.prettierignore +.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.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.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/config/env.ts b/src/shared/config/env.ts new file mode 100644 index 0000000..e2074c7 --- /dev/null +++ b/src/shared/config/env.ts @@ -0,0 +1,131 @@ +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'], { + 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 = isTest ? envSchema.partial().safeParse(process.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/tsconfig.json b/tsconfig.json index cdef9d1..7422fbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", + "src/shared/model/*.d.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"