← назад к разделу

У приложения есть настройки, которые различаются между стендами: адрес бэкенда, ключ карт, включённость экспериментальной вкладки. Обычно их держат в переменных окружения — и на этом месте возникают две ошибки подряд. Первая: считать, что переменная окружения — это секрет. Вторая: узнавать об опечатке в имени переменной по белому экрану на продакшене.

Разберём обе.

Всё, что попало в сборку, — публично

Фронтенд собирается в файлы, которые скачивает браузер. Сборщик подставляет значения переменных прямо в код — буквально, текстом:

// исходник
const url = import.meta.env.VITE_API_URL;

// в собранном файле
const url = "https://api.example.com";

Отсюда простое следствие: в браузере нет секретов. Ключ, попавший в сборку, доступен любому, кто откроет вкладку разработчика — или просто скачает файл скрипта и поищет в нём строку. Ни минификация, ни хитрая склейка не помогают: значение всё равно есть в файле, иначе код не работал бы.

Сборщики стараются об этом напомнить. Vite подставляет в клиентский код только переменные с префиксом VITE_, Next — с NEXT_PUBLIC_. Префикс — не защита, а подпись: «я понимаю, что это увидят все».

Поэтому в переменных фронтенда живут только настройки:

  • адрес API и других сервисов,
  • публичные ключи, которые и рассчитаны на клиент (карты, аналитика, приём платежей на стороне провайдера),
  • включатели возможностей,
  • название стенда и версия сборки для отладки.

А вот чего там быть не должно ни при каких обстоятельствах: ключей платёжного провайдера с правом списания, токенов доступа к базе, паролей интеграций, приватных ключей подписи. Если такому ключу нужно вызвать что-то из браузера — между браузером и провайдером ставят свой бэкенд, и ключ остаётся на нём.

Отдельно: .env не хранят в репозитории (кроме файла-образца без значений), даже если внутри «всего лишь адреса». Файл живёт вместе с проектом дольше, чем содержимое, и однажды туда допишут лишнее.

Опечатка в имени переменной ломает всё молча

Вторая ошибка коварнее. Переменные окружения — это строки, которые никто не проверяет:

const url = import.meta.env.VITE_API_URL;   // undefined, если забыли задать
fetch(`${url}/products`);                   // запрос уходит на /undefined/products

Приложение соберётся, запустится и покажет экран. Ошибка появится позже — при первом запросе, у пользователя, в виде непонятного отказа. Причём чаще всего это происходит именно на новом стенде, где переменную задать забыли.

Лечится одним приёмом: проверять окружение на старте, схемой, целиком.

// src/shared/config/env.ts
import { z } from "zod";

const envSchema = z.object({
  VITE_API_URL: z.string().url(),
  VITE_ENV_NAME: z.enum(["local", "dev", "stage", "prod"]),
  VITE_SENTRY_DSN: z.string().url().optional(),
  VITE_FEATURE_NEW_CART: z.coerce.boolean().default(false),
});

const parsed = envSchema.safeParse(import.meta.env);

if (!parsed.success) {
  const problems = parsed.error.issues
    .map((i) => `${i.path.join(".")}: ${i.message}`)
    .join("\n");
  throw new Error(`Неверные переменные окружения:\n${problems}`);
}

export const env = parsed.data;

Дальше по коду обращаются только к env, а не к import.meta.env напрямую:

import { env } from "@/shared/config/env";

export const apiClient = createClient({ baseUrl: env.API_URL });

Что это даёт. Приложение падает сразу и с внятным текстом, а не через десять минут на непонятном запросе. Типы становятся настоящими: env.VITE_FEATURE_NEW_CART — это boolean, а не строка "false", которая в условии истинна. Значения по умолчанию описаны в одном месте. И появляется список всего, что нужно задать для запуска, — сама схема и есть документация.

Запрет прямого доступа тоже стоит закрепить правилом линтера, иначе через месяц в проекте снова появится import.meta.env.VITE_... в случайном файле:

"no-restricted-properties": ["error", {
  object: "import.meta",
  property: "env",
  message: "Окружение — только через @/shared/config/env",
}],

Проверка стендов между собой

Схема ловит отсутствующую переменную на запуске приложения. Но есть ошибка раньше: переменную добавили в файл для локальной разработки, а в настройки стенда — забыли. Локально всё работает, на стенде падает.

Помогает простой скрипт, который сверяет наборы имён между окружениями и падает, если где-то чего-то нет:

// scripts/env-check.ts
const required = Object.keys(envSchema.shape);
const files = [".env.development", ".env.production"];

for (const file of files) {
  const defined = new Set(readEnvKeys(file));
  const missing = required.filter((key) => !defined.has(key) && !isOptional(key));
  if (missing.length) {
    throw new Error(`${file}: не хватает ${missing.join(", ")}`);
  }
}

Такой скрипт ставят в сборку рядом с линтером. Он занимает вечер, а ловит целый класс ночных разборов «почему на стенде белый экран».

Настройки, которые приходят в бою

Есть случай, когда подстановка на сборке неудобна: один и тот же образ приложения выкатывают на несколько стендов. Пересобирать его ради другого адреса API — значит выкатывать уже не то, что тестировали.

Тогда настройки отдают не сборщиком, а во время выполнения: сервер (или контейнер при старте) кладёт рядом маленький файл с конфигурацией, приложение читает его первым делом. Схема при этом остаётся ровно та же — просто разбирается не import.meta.env, а содержимое ответа.

Правило безопасности не меняется: то, что уехало в браузер, публично. Способ доставки на это не влияет.

Коротко

  • Всё, что сборщик подставил в код, попадает в браузер и доступно любому: секретов во фронтенде нет.
  • Префиксы VITE_ и NEXT_PUBLIC_ — не защита, а явное согласие сделать значение публичным.
  • В переменных фронтенда живут адреса, публичные ключи и включатели; ключи с правом действия — только на бэкенде.
  • Незаданная переменная — это undefined, который ломает приложение молча и позже; проверять её нужно на старте.
  • Схема окружения даёт понятное падение, настоящие типы, значения по умолчанию и список того, что нужно для запуска.
  • Прямой доступ к переменным запрещают правилом линтера, оставляя один модуль-вход.
  • Сверка наборов переменных между стендами ловит «забыли добавить на стенде» до выката.
  • Если один образ едет на несколько стендов, настройки читают во время выполнения, но правило публичности остаётся прежним.

Что почитать дальше

  • Границы архитектуры — как правила линтера удерживают договорённости.
  • Статические сайты и MDX — тот же приём проверки схемой, только для контента.
  • Клиент из OpenAPI — откуда берётся адрес API и типизированный клиент к нему.