Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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=
# Option special per CI workflow
SKIP_ENV_VALIDATION=true/false
5 changes: 4 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build and Push

on:
push:
branches: [dev, main, feat/**]
branches: [ dev, main, feat/** ]

env:
REGISTRY: ghcr.io
Expand Down Expand Up @@ -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 }}
Expand Down
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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
27 changes: 25 additions & 2 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -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
.dockerignore
.gitignore

# Секреты
.env*

.prettierignore
.github/
20 changes: 16 additions & 4 deletions Dockerfile.prod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'shared/config/env';
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
typedRoutes: true,
turbopack: {
root: __dirname,
},

output: 'standalone',
};

Expand Down
10 changes: 10 additions & 0 deletions src/shared/config/env.d.ts
Original file line number Diff line number Diff line change
@@ -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 {};
131 changes: 131 additions & 0 deletions src/shared/config/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<typeof envSchema>;
export const env = _env.data;
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"src/shared/model/*.d.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
Expand Down
Loading