Введение
Нередко в проектах необходимо заводить переменные окружения (environment variables). Существует много способов сделать это. Например, указать переменную «inline», как MY_VAR="my value" node index.js
или обозначить источник командой source
. Некоторые фреймворки имеют даже целые отдельные пакеты для формирования переменных окружения (прим. nest.js). Но чаще всего за годы работы в сфере фронтенд-разработки мне приходилось работать со способом, который подразумевает содержание .env файлов в проекте. Такие файлы имеют простейший синтаксис вида KEY=VALUE
:
# .env APP_TITLE="My application" PORT=3000
Для чего вообще нужны эти переменные и какого рода данные в них стоит записывать? Ответ:
-
какие-либо секретные значения, которые не стоит держать в репозитории.
Пример:KEY_SECRET
от стороннего API или данные для аутентификации БД; -
значения, которые различаются для разных режимов работы приложения. Пример:
BASE_URL
для:-
dev-стенда — https://dev.my-amazing-resource.com
-
test-стенда — https://stage.my-amazing-resource.com
-
production — https://my-amazing-resource.com
-
-
параметры конфигурации или глобальные константы. Пример:
PORT
,PROXY_URL
,FEATURE_TOGGLE
и т.д.
Таким образом, данные скрыты от чужих глаз и могут конфигурировать наше приложение в (и вне) зависимости от режима его работы. Удобно!
Проблема
А проблема в том, что использование env-переменных не гарантирует нам наличие значения и не дает понимание о его типе данных. Взгляните на следующий конфиг:
# .env APP_TITLE="My app" APP_VERSION=1 SHOW_VERSION=true # COMMENTED_REQUIRED_VALUE=
Во-первых, может показаться, что APP_TITLE
, APP_VERSION
и SHOW_VERSION
— это строка, число и логическое значение соответственно. На самом же деле ВСЕ эти значения являются строками.
Во-вторых, имеется закомментированная переменная COMMENTED_REQUIRED_VALUE
, которая неспроста названа именно так. Предположим, что это обязательное значение, без которого приложение не может функционировать.
Что мы имеем по итогу:
-
Для того, чтобы работать со значениями согласно задуманным им типам данных, необходимо сначала привести их к этим типам:
-
const title = process.env.APP_TITLE; // all is ok
-
const version = Number(process.env.APP_VERSION);
-
const showVersion = JSON.parse(process.env.SHOW_VERSION);
На этом этапе приложение может сломать самая малая опечатка в конфиге, например,
False
вместоfalse
или1,0
вместо1
. Стоит подумать на счет разнообразных проверок, обработки ошибки парсинга и прочем. -
-
Так как .env не дает никакой валидации данных, мы, вызывая переменную
COMMENTED_REQUIRED_VALUE
, которая считается обязательной и всегда должна быть прописана, НО почему-то осталась закомментированной, получаем ошибку. Окей, просто добавить еще проверок наличия обязательного значения, приведения типов, проверок типов.
Добавили все необходимые приведения типов, проверки, и теперь код…
Нет.
Решение v0
Наверняка, вы уже самостоятельно додумали, что было дальше. Такое решение не очень-то красиво и удобно. Особенно, учитывая гораздо большее количество переменных, появление новых и другие аспекты. В этом разделе я хочу представить 🙌 хорошее 🙌 решение и более подробно разобрать сам подход, а в следующем разделе поговорим о его «доведенной до ума» версии — ✨ отличном ✨ решении.
За основу будет взят проект, созданный Vite по шаблону react-swc-ts
, что значит React + SWC + TypeScript. На самом деле, описываемый подход не зависит от конкретно этих инструментов и может быть применен практически к любому стеку.
Установил зависимости, поставил prettier, настроил линтинг, отформатировал код, поставил @types/node, поправил package.json, поправил vite.config.ts. Теперь хорошо. Я не буду углубляться в структуру проекта, рассказывать какие-то подробности Vite или React, если они НЕ ИМЕЮТ отношения к делу. Только суть.
yarn dev # или vite
Чтобы избавиться от «лишнего шума» я удалю весь код, сгенерированный Vite и оставлю только один заголовок h1
, который послужит для визуализации значений.
Итак, я хочу иметь в приложении конфиг, описанный выше в качестве примера, так что я создам в корне проекта следующий .env файл:
APP_TITLE="My app" APP_VERSION=1 SHOW_VERSION=true # COMMENTED_REQUIRED_VALUE=
Vite предоставляет немного своеобразный способ работы с переменными окружения, в частности с .env. Подробнее об этом можно прочитать в официальной документации, но пока что следует знать только то, что все переменные должны быть указаны с преифксом VITE_
, а вызов этих переменных внутри приложения происходит через специальный объект import.meta.env
.
Таким образом, получаем конфиг такого вида:
VITE_APP_TITLE="My app" VITE_APP_VERSION=1 VITE_SHOW_VERSION=true # VITE_COMMENTED_REQUIRED_VALUE=
И в приложении попробуем использовать переменную VITE_APP_TITLE
, просто поместив ее внутрь заголовка h1
, пользуясь интерполяцией JSX(TSX):
export const App = () => { return <h1>{import.meta.env.VITE_APP_TITLE}</h1>; };
Отлично! Это работает. Но если посмотрим в коде на тот самый специальный объект import.meta.env
, то увидим, что он вроде как и не содержит нужных нам свойств. Более того, любое его свойство, помимо встроенных BASE_URL
, MODE
, DEV
, PROD
, SSR
, является значением типа any
.
Посмотрим на решение, которое предоставляет Vite. Необходимо описать модуль env.d.ts, предварительно добавив его в директорию src или куда вам угодно, например, в src/types:
/// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_APP_TITLE: string; readonly VITE_APP_VERSION: number; readonly VITE_SHOW_VERSION: boolean; readonly VITE_COMMENTED_REQUIRED_VALUE: any; } interface ImportMeta { readonly env: ImportMetaEnv }
Выглядит неплохо, теперь мы можем просматривать все переменные окружения, которые описали в модуле и опираться на их типы данных.
Но вопрос с валидацией данных остается открытым. IDE подсказывает, что переменная VITE_APP_VERSION
является значением типа number
. Но по факту, проверив значение через typeof
, мы получаем string
:
Такое решение может, скорее, сбить столку. Поэтому приступим к написанию собственного. Для начала добавим следующую структуру файлов: в директории src создаем новую директорию config, в которую помещаем два .ts
документа:
-
index.ts
-
buildEnvProxy.ts
buildEnvProxy.ts будет содержать в себе и экспортировать одноименную функцию, которая принимает на вход следующие параметры: source — источник, из которого берутся переменные окружения (как вы помните, в нашем случае это import.meta.env
) и необязательный transformKey — функция, которая поможет преобразовать ключи (названия переменных) конфига, если это потребуется.
С источником понятно, но зачем преобразовывать ключи? Дело в том, что мне бы не хотелось постоянно использовать префикс VITE_
, поэтому через Proxy
я буду подставлять его автоматически.
Функция возвращает этот самый Proxy
, который имеет только один handler — get
. Его и опишем.
Так как ключ в объекте — это string | symbol
, а наш конфиг, по идее — это объект типа Record<string | unknown>
, где ключ — это string
, то нужно сначала привести этот ключ к строке с помощью String
.
Когда ключ в строковом формате имеется, нужно провести проверку на наличие функции transformKey и, если она существует — выполнить преобразование. В конечном итоге get возвращает значение из source по ключу, который был приведен к строке и преобразован при наличии функции transformKey:
// buildEnvProxy.ts export const buildEnvProxy = <T extends Record<string, unknown>>( source: T, transformKey: (key: string) => string, ) => new Proxy({} as T, { get(_, key) { const keyStr = String(key); const envKey = transformKey ? transformKey(keyStr) : keyStr; return source[envKey]; }, });
Внутри index.ts объявляем константу ENV и присваиваем ей результат выполнения описанной выше функции, указав в качестве generic Record<string, unknown>
, а в качестве параметров: источник — import.meta.env
и функцию преобразователь, которая подставляет к ключу корректный префикс:
// index.ts import { buildEnvProxy } from './buildEnvProxy.ts'; const ENV = buildEnvProxy<Record<string, unknown>>( import.meta.env, (key) => `VITE_${key}`, );
Проверяем:
console.log('Without prefix:', ENV.APP_TITLE); console.log('With prefix', ENV.VITE_APP_TITLE);
Получаем результат:
Теперь приступим непосредственно к типизации конфига. Первым делом в этой же директории создаем документ config.types.ts, в котором описываем и экспортируем type Config
с типами данных значений:
// config.types.ts export type Config = { APP_TITLE: string; APP_VERSION: number; SHOW_VERSION: boolean; COMMENTED_REQUIRED_VALUE: any; };
Вот на этом моменте получается что-то похожее на решение, предоставленное Vite, т.к. типизация под конфиг имеется, но значения никак не преобразуются и по прежнему остаются типа string
.
Для дальнейшего преобразования, внутри директории config заводим новую директорию configGetters, а в ней три документа, для начала:
-
getBoolean.ts
-
getNumber.ts
-
getString.ts
Как вы уже догадались, эти документы будут содержать одноименные функции, которые соответствуют типам данных указанных в конфиге переменных. Именно они будут отвечать за преобразование.
// getBoolean.ts export const getBoolean = ( target: Record<string, unknown>, key: string, ): boolean => { const value = target[key]; try { if (value === 'true' || value === true) return true; if (value === 'false' || value === false) return false; throw new Error(); } catch { throw new Error(`Config value for "${key}" is not a boolean: "${value}"`); } };
// getNumber.ts export const getNumber = ( target: Record<string, unknown>, key: string, ): number => { const value = target[key]; try { const numValue = Number(value); if (isFinite(numValue) && !isNaN(numValue)) return numValue; throw new Error(); } catch { throw new Error(`Config value for "${key}" is not a number: "${value}"`); } };
// getString.ts export const getString = ( target: Record<string, unknown>, key: string, ): string => { const strValue = target[key]; try { if (typeof strValue === 'string') return strValue; throw new Error(); } catch { throw new Error(`Config value for "${key}" is not a string: "${strValue}"`); } };
Если значение из конфига не соответствует ожиданиям — будет выброшена ошибка.
Помимо создания функций, необходимо также указать, какая из функций соответствует конкретной переменной. Для этого поднимемся на уровень выше, т.е. вернемся в директорию config и создадим еще один документ — config.gettersMap.ts, в котором привяжем функции к переменным:
// config.gettersMap.ts import type { Config } from './config.types.ts'; import { getString } from './configGetters/getString.ts'; import { getNumber } from './configGetters/getNumber.ts'; import { getBoolean } from './configGetters/getBoolean.ts'; export const CONFIG_GETTERS_MAP: { [K in keyof Config]: (target: Record<string, unknown>, key: K) => Config[K]; } = { APP_TITLE: getString, APP_VERSION: getNumber, SHOW_VERSION: getBoolean, COMMENTED_REQUIRED_VALUE: getString };
Создается и экспортируется объект, обязательными ключами которого являются ключи типа Config
, т.е. наши переменные окружения. И каждому ключу присваивается соответствующая функция.
Вы могли обратить внимание на то, что до сих пор в данной секции ни разу не упоминалась переменная COMMENTED_REQUIRED_VALUE
. Позже я раскрою все карты, а пока что просто присвоим ей функцию getString
.
Заключительной частью данной секции является сбор всего того, что мы написали вместе. Для этого на том же уровне создаем еще один, последний документ — buildConfigProxy.ts. Документ содержит одноименную функцию, которая принимает:
-
generic для того, чтобы обозначить тип возвращаемого значения;
-
два параметра:
-
env — источник переменных окружения, который мы получили ранее с помощью функции
buildEnvProxy
-
envGettersMap — наш объект с присвоенными функциями преобразования —
CONFIG_GETTERS_MAP
-
Результатом выполнения функции будет полноценный конфиг, который прошел все необходимые проверки на наличие значений и их соответствие указанным типам.
// buildConfigProxy.ts export const buildConfigProxy = <T extends Record<string, unknown>>({ env, envGettersMap, }: { env: Record<string, unknown>; envGettersMap: { [K in keyof T]: (target: Record<string, unknown>, key: K) => T[K]; }; }) => new Proxy({} as T, { get(_, key) { const keyStr = String(key); const getter = envGettersMap[keyStr]; if (!getter || typeof getter !== 'function') { throw new Error(`Config: Proxy getter for "${keyStr}" is not defined`); } return getter(env, keyStr); }, });
Остается только дополнить index.ts вызовом данной функции:
// index.ts import { buildEnvProxy } from './buildEnvProxy.ts'; import { buildConfigProxy } from './buildConfigProxy.ts'; import { CONFIG_GETTERS_MAP } from './config.gettersMap.ts'; import type { Config } from './config.types.ts'; const ENV = buildEnvProxy<Record<string, unknown>>( import.meta.env, (key) => `VITE_${key}`, ); export const CONFIG = buildConfigProxy<Config>({ env: ENV, envGettersMap: CONFIG_GETTERS_MAP, });
В качестве generic указали тип Config
, о чем уже подсказывает IDE:
Проверяем:
# .env VITE_APP_TITLE="My app" VITE_APP_VERSION=1 VITE_SHOW_VERSION=true # VITE_COMMENTED_REQUIRED_VALUE=
// App.tsx import { CONFIG } from './config'; export const App = () => return ( <h1> {CONFIG.SHOW_VERSION && ( <> {CONFIG.APP_TITLE} v{CONFIG.APP_VERSION.toFixed(1)} </> )} </h1> ); };
Получаем результат:
Как видите, все значения были получены и код отработал корректно. Об этом свидетельствует, как отображение заголовка h1
, так и выполненный над переменной CONFIG.APP_VERSION
метод .toFixed(1)
, присущий типу number
.
Теперь в .env закомментируем любую вызываемую переменную, например, VITE_APP_TITLE
:
# VITE_APP_TITLE="My app" VITE_APP_VERSION=1 VITE_SHOW_VERSION=true # VITE_COMMENTED_REQUIRED_VALUE=
Получаем результат в качестве результата ошибку Config value for "APP_TITLE" is not a string: "undefined"
и сразу понимаем, что стоит поправить в конфиге:
И для завершения раскомментируем переменную VITE_APP_TITLE
и изменим теперь уже значение переменной VITE_APP_VERSION
:
VITE_APP_TITLE="My app" VITE_APP_VERSION=1,0 VITE_SHOW_VERSION=true # VITE_COMMENTED_REQUIRED_VALUE=
Результат — ошибка Config value for "APP_VERSION" is not a number: "1,0"
:
Круто! Мы проделали хорошую работу: конфиг теперь типизирован и свалидирован. Помимо, простейших преобразований и проверок, таких как getNumber
, getString
и getBoolean
, вы можете сделать любые другие. Например, enum:
export const getEnum = <T>( getter: (target: Record<string, unknown>, key: string) => unknown, allowedValues: T[], ): ((target: Record<string, unknown>, key: string) => T) => (target, key) => { const value = getter(target, key) as T; try { if (allowedValues.includes(value)) return value; throw new Error(); } catch { throw new Error( `Config value for "${key}" is not one of allowed values [${allowedValues}]: "${value}"`, ); } };
Такая функция принимает на вход другую функцию геттер и список допустимых значений, а также имеет generic с указанием этих значений. Вот пример использования:
... MODE: getSpecific<'production' | 'development' | 'test'>(getString, [ 'production', 'development', 'test', ]), ...
На этом секция про 🙌 хорошее 🙌 решение подошла к концу. Вот такая структура получилась в итоге:
Но что же с VITE_COMMENTED_REQUIRED_VALUE
? Эта переменная как раз напрямую связана со вторым, ✨ отличным ✨ способом решения проблемы.
Решение v1
Итак, что если в конфиге необходимо обозначить объект?
VITE_COMMENTED_REQUIRED_VALUE
как раз подразумевалась, как такая переменная.
Можно написать еще функций преобразования, подобных getNumber
, getBoolean
и т.д., но если таких переменных будет много и они все будут содержать объекты разных структур, то это уже не кажется хорошей идеей.
Здесь на помощь приходит Zod.
На официальном сайте сказано следующее:
Zod — это TypeScript-first библиотека объявления и проверки схем. Я использую термин «схема» для широкого обозначения любого типа данных от простого
string
к сложному вложенному объекту.
Проще говоря, мы можем создать какую-либо схему, а затем по этой схеме валидировать значения. Пример (тоже с оф. сайта):
import { z } from "zod"; // создание схемы для строк (string) const mySchema = z.string(); // парсинг mySchema.parse("tuna"); // => "tuna" mySchema.parse(12); // => throws ZodError // "безопасный" парсинг (не throw error валидация провалилась) mySchema.safeParse("tuna"); // => { success: true; data: "tuna" } mySchema.safeParse(12); // => { success: false; error: ZodError }
И еще немного значимых плюсов (опять же указанных на оф. сайте):
-
0 зависимостей;
-
работает в Node.js и всех современных браузерах;
-
крошечный: 8kb minified + zipped;
-
работает с JS (TS необязателен) — в данном случае неактуально, но круто!
Приступим к внедрению Zod. Первым делом устанавливаем библиотеку, я сделаю это с помощью yarn:
yarn add zod
А затем удаляем из директории config ВСЕ, что мы написали в секции «Решение v0», кроме index.ts и buildEnvProxy.ts. Да, нам больше не требуются функции преобразования, buildConfigProxy
, CONFIG_GETTERS_MAP
— Zod все сделает за нас! (почти)
Создаем в директории новый документ — config.schema.ts. В этом документе опишем схему конфига и экспортируем функцию, которая будет принимать на вход наш ENV, парсить его и в случае успешного парсинга возвращать валидный объект конфига. В случае неудачного парсинга из-за неправильно указанного (или вовсе неуказанного) значения переменной окружения, функция будет выбрасывать ошибку.
Так как конфиг — это объект, обозначим схему, как z.object({})
, а внутри, в фигурных скобках опишем этот объект с помощью других методов Zod’а:
// config.schema.ts import { z } from 'zod'; const configSchema = z.object({ APP_TITLE: z.string(), APP_VERSION: z.number(), SHOW_VERSION: z.boolean(), });
Ниже экспортируем ту самую функцию, которая будет парсить конфиг:
export const parseConfig = (configObj: Record<string, unknown>) => { const parseResult = configSchema.safeParse(configObj); if (!parseResult.success) throw parseResult.error; return parseResult.data; }
Внутри вызываем у схемы метод safeParse, в который передаем объект конфига. Результатом выполнения метода будет некий объект, который всегда содержит логическое свойство success
, а также data
в случае успеха и error
в случае неудачи. Мы используем все эти свойства и в случае success
возвращаем data
, а в случае неудачи выбрасываем error
.
Далее идем в index.ts
и убираем все лишнее, то есть несуществующие импорты (потому что ранее мы удалили экспорты) и значение экспортируемой константы CONFIG
, мы его перепишем:
import { buildEnvProxy } from './buildEnvProxy.ts'; import { parseConfig } from './config.schema.ts'; const ENV = buildEnvProxy<Record<string, unknown>>( import.meta.env, (key) => `VITE_${key}`, ); export const CONFIG = parseConfig(ENV); export type Config = typeof CONFIG;
Теперь в константу CONFIG
будет записан полностью валидный объект со всеми необходимыми переменными окружения, а если возникнет ошибка, Zod скажет об этом еще до отрисовки нашей страницы. Проверяем:
И сразу получаем ошибку. А также обратите внимание на белую страницу, она действительно даже не отрисовалась! В ошибке говорится о том, что переменные APP_VERSION
и SHOW_VERSION
должны быть number
и boolean
соответственно, но приходит string
. Так и есть, как помните все указанные значения в .env являются строками. Следовательно, необходимо выполнить преобразование этих строк, и Zod имеет несколько способов сделать это. Я воспользуюсь способом «принуждение» — .coerce
, потому что пока что мы имеем дело с примитивами (сейчас объясню):
const configSchema = z.object({ APP_TITLE: z.string(), APP_VERSION: z.coerce.number(), SHOW_VERSION: z.coerce.boolean(), });
В данной схеме как бы говорится «возьми переменную APP_VERSION
и приведи ее к типу number
«. Аналогично и с SHOW_VERSION
. Если приведение к указанному типу и последующая валидация полученного значения проходит неудачно — получаем ошибку. Проверяем с правильно указанными значениями:
Все работает отлично. А теперь исправляем APP_VERSION
на 1,0 и получаем ошибку о том, что ожидается значение типа number
, но получается NaN
(да, я знаю, но вот так — хорошо же!). Если закомментировать в конфиге какую-либо из описанных в схеме переменных — также получим ошибку, но о том, что вместо ожидаемого значения пришло undefined
.
Все работает, как надо и Zod уже закрыл все изначальные потребности. Наконец займемся переменной COMMENTED_REQUIRED_VALUE
. Определимся, что данная переменная должна содержать массив простых объектов, что-то вроде:
Array<{ solution: string; rate: 'A' | 'B'; }>
Сразу становится понятно, что такой массив необходимо передавать в формате JSON, поэтому опишем переменную в конфиге следующим образом:
VITE_APP_TITLE="My app" VITE_APP_VERSION=1 VITE_SHOW_VERSION=true VITE_COMMENTED_REQUIRED_VALUE='[{"solution":"v1","rate":"A"},{"solution":"v0","rate":"B"}]'
И приступим к описанию данного значения в схеме. Это будет выглядеть, как цепочка проверок и преобразований.
Абсолютно точно, изначально значение является строкой, поэтому необходимо указать метод .string()
. Когда получаем валидную строку, нужно проверить, что она также является валидным JSON-форматом. У Zod нет решения «из коробки» конкретно для JSON, но зато есть метод .transform()
, который принимает в качестве параметра функцию преобразователь. Эта функция в свою очередь в качестве параметра имеет наше дошедшее к этому моменту значение — просто валидную строку. Эту строку нужно распарсить, как JSON — это и есть проверка и преобразование. Получается .transform((value) => JSON.parse(value))
. А дальше все, как и в случае со схемой самого конфига: .object(...)
внутри которого описываем свойства solution
и rate
. Но для того, чтобы сделать проверку над преобразованным методом .transform()
значением, необходимо обернуть его методом .pipe()
, то есть обозначить «пайплайн» проверки.
И единственное отличие в том, что данный преобразованный объект все таки является массивом. Для этого указания просто добавляем после метода .object()
метод .array()
: .object(...).array()
. Посмотрим, что получилось:
... COMMENTED_REQUIRED_VALUE: z .string() // Проверяем строку .transform((val) => JSON.parse(val)) // Преобразуем в JSON .pipe( // Указываем "пайплайн" z .object({ // Проверяем объект solution: z.string(), rate: z.enum(['A', 'B']), }) .array(), // Проверяем, что это именно массив объектов ), ...
В коде использовался еще один метод, который не упоминался ранее — .enum()
. С помощью данного метода можно строго задать значения, которые могут быть приняты на вход. Кстати, помимо таких «enum’ов», Zod умеет также работать с нативными enum
.
В конце концов слегка дополним компонент и посмотрим, что получилось:
import { CONFIG } from './config'; export const App = () => { return ( <> <h1> {CONFIG.SHOW_VERSION && ( <> {CONFIG.APP_TITLE} v{CONFIG.APP_VERSION.toFixed(1)} </> )} </h1> {CONFIG.COMMENTED_REQUIRED_VALUE.map(({ solution, rate }) => ( <div> Solution {solution} — {rate} </div> ))} </> ); };
Результат:
На этом раздел, посвященный ✨ отличному ✨ решению заканчивается, как и статья.
Как результат, мы имеем типизированный и валидный конфиг, в который можно поместить самые разнообразные данные, удобно использовать их в приложении и, в случае невалидности, получить подробную и понятную ошибку.
Большим плюсом является то, что данное решение подходит для множества окружений: если проект Vite — используем функцию buildEnvProxy
, куда передаем источник и transformKey
, который подставляет префикс _VITE
; если вдруг проект переводится на Webpack, то достаточно просто передать в функцию новый источник и убрать transformKey
. А вот так можно улучшить эту функцию:
export const buildEnvProxy = <T extends Record<string, unknown>>( envSources: { source: Partial<T>; transformKey?: (key: string) => string; }[], ) => new Proxy({} as T, { get(_, key) { return envSources .map(({ source, transformKey }) => { const keyStr = String(key); const envKey = transformKey ? transformKey(keyStr) : keyStr; return source[envKey]; }) .find((v) => v !== undefined); }, });
Теперь мы можем передавать сразу несколько источников и указывать или не указывать transformKey
для каждого из них. Handler get
будет искать запрашиваемую переменную последовательно во всех источниках, а порядок источников можно менять и тем самым настраивать приоритет.
Надеюсь большинство останется со мной солидарно, но буду рад обсуждению тех или иных моментов и предложениям по улучшению решения. Если что, задача на рефакторинг не заставит себя ждать 🙂
Здесь гитхаб репозиторий с каждым решением (двумя) в отдельной ветке: https://github.com/bodasooqa/vite-react-config-validation
ссылка на оригинал статьи https://habr.com/ru/articles/743798/