Понедельник, багрепорт от партнёра: «у нас поехала миникарта, наезжает на кнопки».

Первый вопрос, который я задаю команде, — не «что сломалось». Первый вопрос: в каком из форков?

Это плохой первый вопрос. Если он у вас возникает, значит, где-то на пути от «сделаем клиенту красиво» до «поддерживаем это в проде» архитектура свернула не туда. У нас в Getfloorplan она свернула не туда примерно на пятом клиенте, а осознали мы это ближе к сотому.

Вот как выглядит место преступления сегодня, спустя годы после того, как мы перестали так делать:

около сотни репозиториев-виджетов в GitLab
 девять из десяти — архивированы
  последняя активность у большинства — прошлый год

Это кладбище. Каждая могила — живой когда-то клиент, под которого форкнули репозиторий, поправили конфиг, выкатили и забыли.

Привет! Меня зовут Руслан Мамлеев, я технический директор (CTO) в Getfloorplan. В прошлой статье рассказывал, как мы собрали живой инвентарь инфраструктуры ИИ-агентами. Сегодня — история постарше и поглубже: о том, как продукт с сотней форков превратился в один репозиторий и одну схему, и почему я до сих пор считаю это лучшим архитектурным решением, которое мы приняли.

Сразу оговорюсь по заголовку. Релизы ядра у нас по-прежнему делают разработчики: тег, CI, npm. А вот релиз клиента — новый партнёр со своим брендом, цветами, набором кнопок и языком — больше нет. Ни одного коммита, ни одной сборки, ни одного разработчика в цепочке. Восемьсот раз подряд. Именно про это статья.

Ниже — как мы из этого выбирались: TypeScript-тип как исходник контракта, JSON Schema в S3 с версионированием, Monaco Editor в админке и раздача брендинга данными вместо сборок. Всё это работает в проде три года и обслужило больше восьмисот брендированных развёртываний.

Имя пакета в статье условное — @getfloorplan/widget; домены тоже (example.com). Цифры, код и логика — настоящие.


Что за продукт и почему всё пошло не так

Getfloorplan делает 3D-туры по планировкам недвижимости. Продукт живёт как виджет, который партнёр встраивает к себе на сайт через iframe: классифайд, застройщик, агентство. Пользователь смотрит квартиру сверху, ходит по ней, меряет линейкой расстояние от спальни до кухни.

Дистрибуция вся через iframe, и здесь начинается интересное. Ссылка на виджет состоит из двух частей:

https://acme.widget.example.com/?id=d2837bcb-69a4-4df4-b80f-3c96113e8f18
       └──── КАК показать ────┘        └──────── ЧТО показать ────────┘

Правая часть — UUID проекта, по нему бэкенд отдаёт сцену. Левая часть — домен — определяет, как эта сцена выглядит: цвета, шрифты, набор вкладок, положение миникарты, включена ли линейка.

И вот в чём засада. Почти каждому B2B-клиенту нужна своя конфигурация. У всех свой брендбук, у многих поверх виджета лежит оверлей, из-за которого наши кнопки надо поднять на двадцать пикселей. Плюс отдельная категория — premium и white-label, которые платят за то, чтобы виджет вообще не выглядел как наш.

Одна планировка, 80 брендингов: цвета, шрифты, логотипы, положение миникарты, набор кнопок, копирайт
Одна планировка, 80 брендингов: цвета, шрифты, логотипы, положение миникарты, набор кнопок, копирайт

Одна и та же квартира. Ни одной пересборки — только строки в базе.

Первое решение было очевидным и на тот момент правильным: есть npm-пакет @getfloorplan/widget с ядром и тонкий репозиторий-обёртка со ста строчками конфигурации. Форкаем обёртку под клиента, правим конфиг, деплоим на свой поддомен.

На пяти клиентах это отличная схема. Дальше она разлагается по вполне механической траектории.

Шаг 1. Форк фиксирует версию ядра. Обёртка ставит @getfloorplan/widget той версии, что была актуальна в день форка. Обновлять её никто не будет: работает — не трогай.

Шаг 2. Версии расползаются. За четыре года мы опубликовали четыре с половиной сотни версий пакета в трёх десятках минорных линий. Даже если в проде живёт малая их часть — это десятки одновременно работающих версий ядра.

Шаг 3. Багфикс превращается в миграционную кампанию. Нашли утечку памяти в рендере — надо найти все форки на уязвимой версии, обновить каждый и в каждом проверить, что кастомизация не поехала. Параллельно DevOps обслуживает кладбище: шаблон CI обновляется во всех форках, Nginx-конфиг растёт, сертификат под каждый поддомен выписывается руками.

Шаг 4. Разработка перестаёт разрабатывать. Вот тут больно по-настоящему. Жизненный цикл подключения клиента выглядел так:

Шаг

Канал

Время

Что болит

1. Демо референс-проекта

Zoom, почта

0,5–1 д

Бренд клиента нужен уже на демо

2. Сбор требований

Тикеты

1–3 д

Забывают мелочи, идут доп. раунды

3. Форк репозитория

GitLab

1 ч

Механика, но нужны Nginx + CI

4. Ручное внесение настроек

IDE

1–2 д

Нет схемы и валидации, легко ошибиться

5. Сборка и деплой

GitLab runner

15–30 мин

При потоке клиентов очередь растёт

6. Обратная связь, правки

Почта, чат

1–5 д

Каждый раунд = новый коммит и билд

7. Поддержка в проде

Sentry, Grafana

Для багфикса найди все форки с этой версией

Шаги 4 и 6 — это разработчик. Причём разработчик, который двигает плашку на двадцать пикселей и меняет hex-код кнопки.

Подключение клиента занимало до двух недель календарного времени при потоке в несколько клиентов в неделю. Арифметика не сходится.

Репозитории-форки по году последней активности: около сотни, девять из десяти в архиве
Репозитории-форки по году последней активности: около сотни, девять из десяти в архиве

Отдельная боль: никто не знал, что вообще можно настроить

Допустим, в версии 3.0 мы сделали линейку. У неё есть настройки. В версии 2.0 линейки не существует.

Продажник открывает конфиг клиента, который сидит на 2.4.7, и хочет включить линейку. Может он? Узнать это он мог только сходив к разработчику. Описание параметров жило в трёх местах: в коде, в устаревающей документации и в голове того, кто это писал. Единого источника правды не было.

Это, как выяснилось позже, и была настоящая проблема. Не форки — форки симптом.

Было: форк на клиента, pinned-минор, свой CI и Nginx. Стало: TConfig.ts → CI → S3 → админка → БД → wildcard-домен
Было: форк на клиента, pinned-минор, свой CI и Nginx. Стало: TConfig.ts → CI → S3 → админка → БД → wildcard-домен

Контракт конфигурации: тип как исходник, схема как исполняемый контракт

Ключевая мысль, ради которой я и пишу эту статью.

У нас уже был TypeScript. Конфигурация виджета описана типом — со всеми union-ами, енумами и булевыми флагами:

export type TWidgetTab = 'rotation' | 'plan' | 'panorama' | 'carousel';

export type TConfig = {
  /** Widget locale */
  locale: string;

  /** Path/link to the logo */
  logoUrl: string;

  /**
   * Widget color settings
   * - `main`: main color of buttons, elements
   * - `mainText`: text color for buttons contrasting with the main color
   */
  colors: {
    main: string;
    mainText?: string;
  };

  /** First opened tab */
  primaryTab: TWidgetTab;

  /** Enable/disable auto rotation in 3D tour */
  isAutoRotate: boolean;

  // ... ещё несколько десятков параметров
};

Наружу отдаётся не весь тип, а его внешняя проекция:

export type TExternalConfig = Omit<Partial<TConfig & TLegacyConfig>, 'query'>;

Partial — потому что клиент присылает патч поверх дефолтов, а не полный конфиг. Omit<..., 'query'> вырезает поле, которое виджет заполняет из URL сам.

Смотрите, что здесь уже есть, бесплатно, просто потому что разработчики пишут на TypeScript и не ленятся ставить нормальные типы:

  • isAutoRotate строго булевый — ни "yes", ни 1

  • primaryTab — одно из четырёх значений, panorama1 невалидно

  • у каждого поля есть TSDoc-комментарий, объясняющий, что оно делает

Это и есть искомый источник правды. Он уже был. Мы просто не умели его достать оттуда, где он лежал.

Оговорка, чтобы не скатиться в магию: сам TypeScript-тип в рантайме не существует — он стирается при компиляции. Он хорош как место, где контракт пишут люди. Проверять данные умеет то, что из него сгенерировано, — JSON Schema.

Достаём: один вызов и один CI-джоб

npx ts-json-schema-generator \
  --path src/config/TExternalConfig.ts \
  --type 'TExternalConfig' \
  --out config.schema.json

TSDoc-комментарии превращаются в description, union-типы — в enum, опциональные поля — в необязательные свойства схемы.

Дальше — CI-джоб, который срабатывает по тегу, после публикации пакета в npm:

publish:schemas:
  stage: publish
  script:
    - ./generate_and_publish_schema.sh
  needs: ["publish:npmjs"]
  rules:
    - if: $CI_COMMIT_TAG

Скрипт кладёт в S3 раскладку:

<schemas-bucket>/@getfloorplan-widget/widget-config-schemas/
├── latest/{config.schema.json, meta.json}
├── versions/
│   ├── 4.8.31/{config.schema.json, meta.json}
│   └── ...
├── changelogs/4.8.31.md
└── versions.json

Рядом со схемой — meta.json, паспорт релиза:

{
  "version": "4.8.31",
  "releasedAt": "2026-08-26T08:48:44Z",
  "description": "fix: localize 422 screen instead of showing backend message",
  "commit": { "sha": "3caf065...", "author": "...", "date": "..." },
  "ci": { "pipelineId": "51531", "pipelineUrl": "...", "jobId": "..." }
}

По любому параметру находим первую схему, где он появился, а meta.json связывает её с коммитом и пайплайном. База данных для этого не нужна: старые версии не меняются, только добавляются новые, и обычный объектный сторедж закрывает задачу целиком.

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

Правило latest

latest обновляется не всегда — только если собираемый тег совпадает с тем, что npm считает последней версией:

NPMJS_VERSION=$(npm view @getfloorplan/widget dist-tags.latest)

if [ "$CI_COMMIT_TAG" == "$NPMJS_VERSION" ]; then
  aws s3 cp config.schema.json "$S3_BASE_PATH/latest/config.schema.json"
fi

Иначе хотфикс в старую линию (собираем тег 4.6.12, когда в проде уже 4.8.x) перезатёр бы latest схемой, в которой нет половины актуальных параметров, и редактор начал бы подсвечивать валидные конфиги. Backport при этом публикуется с явным --tag, чтобы не двигать dist-tag. Пять строк баша — но без них система ломается тихо и в самый неудобный момент.

Что получилось на выходе

Сегодняшняя схема latest, если её измерить:

  • под сотню параметров верхнего уровня, с учётом вложенности — несколько сотен

  • почти у каждого есть человеческое описание

  • в S3 лежит полторы сотни версий схемы

И вот главное. Документацию к параметрам мы перестали вести отдельно. Разработчик пишет TSDoc над полем — потому что это нормальная гигиена кода, а не потому что мы завели процесс, — и текст сам доезжает до продажника подсказкой в редакторе. Отдельной рутины по синхронизации типов и документации не появилось. В этом и был смысл.


Monaco в админке

Схема лежит в S3. Теперь надо дать человеку интерфейс.

Мы взяли Monaco Editor — редактор, на котором построен VS Code, доступный отдельной библиотекой. Он же под капотом у веб-редактора GitHub и у Web IDE в GitLab. И у него есть встроенная поддержка JSON Schema, о которой удивительно мало кто знает.

Ядро привязки схемы:

const schema = await fetch(schemaUrl).then((r) => r.json());

monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
  validate: true,
  schemaValidation: 'error',
  schemas: [
    {
      uri: schema.$id ?? schemaUrl,
      fileMatch: [model.uri.toString()],
      schema,
    },
  ],
});

Никакой кастомной валидации. Ссылка на схему — и редактор начинает:

  • подсказывать имена параметров по Ctrl+Space

  • подставлять допустимые значения енумов — не надо помнить, что вкладок ровно четыре

  • показывать документацию при наведении — тот самый TSDoc

  • подчёркивать ошибки прямо во время ввода

Ctrl+Space в Monaco: список параметров is* из актуальной схемы
Ctrl+Space в Monaco: список параметров is* из актуальной схемы

Набрал "is — получил все булевы флаги этой версии. Ни одного нет в памяти продажника, все есть в схеме.

Наведение на primaryTab показывает TSDoc-описание «First opened tab»
Наведение на primaryTab показывает TSDoc-описание «First opened tab»

Подсказка при наведении — тот самый TSDoc из TConfig.ts, без единой строчки отдельной документации.

Продажник открывает controls, жмёт Ctrl+Space и видит список: ruler, scale, autoRotate, furnished, prevPanorama, nextPanorama, 2d, 3d, 360, isometry, floors. С описанием каждого. Напечатает panorama1 — редактор подчеркнёт и объяснит, что такого значения не существует. Невалидный конфиг сохранить нельзя: кнопка заблокирована, пока в редакторе есть ошибки.

panorama1 подчёркнут, панель проблем: Value is not accepted. Valid values: rotation, plan, panorama…
panorama1 подчёркнут, панель проблем: Value is not accepted. Valid values: rotation, plan, panorama…

Справа виджет уже откатился на дефолтную вкладку — ошибка в конфиге видна сразу, ещё до сохранения.

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

Выбор версии схемы

Рядом с редактором — дропдаун версий из versions.json. Клиент сидит на 4.4.9? Переключаем схему на 4.4.9, и редактор подсказывает по правилам той версии: параметров, которых тогда не было, он не предложит.

Та самая проблема «никто не знает, что доступно в какой версии» закрылась выпадающим списком.

Живой предпросмотр

Справа от редактора — iframe с реальным виджетом на реальной планировке.

Админка: слева Monaco с CSS и JSON и выбором версии схемы, справа живой брендированный виджет
Админка: слева Monaco с CSS и JSON и выбором версии схемы, справа живой брендированный виджет

Ctrl+S — сохранили, iframe перезагрузился, кнопка переехала. Цикл обратной связи схлопнулся с «полтора дня и новый билд» до двух секунд. Пункт 6 из таблицы выше — «1–5 дней на раунд правок» — просто исчез из процесса.

CSS-редактор рядом — тоже Monaco, только с языком css.

А если продажники не хотят смотреть в JSON

Это первый вопрос, который задают, когда я рассказываю эту историю, и он правильный: «Monaco — это всё-таки код. А если у вас продажники не готовы?»

У нас готовы — команда достаточно техническая, чтобы разобраться в JSON за полчаса по шпаргалке. Но мы понимаем, что так не везде. Поэтому поверх той же схемы собрали генератор формы. Логика прямая:

В схеме

В интерфейсе

boolean

чекбокс

enum

селект

number / integer

числовой инпут с min/max

string с format: uri

инпут с проверкой ссылки

массив из enum

мультиселект

вложенный объект

раскрывающаяся группа полей

всё, что не распозналось

маленький JSON-редактор на месте поля

description из схемы становится подписью под полем. Вышла новая версия виджета с новым параметром — форма его сама нарисовала: без правок в админке, без участия разработчика. Интерфейс строится от релизного цикла другого сервиса.

Держим это под фича-флагом и включаем точечно — потому что продажникам быстрее в Monaco, а форма нужна скорее для внешних партнёров и дилеров, которым скоро откроем self-service. Но логика одна: схема — это не только валидация, это ещё и готовое описание интерфейса. Странно было бы этим не воспользоваться.


Как настройка доезжает до пользователя

Здесь всё коротко, потому что всё просто.

В ссылке, которую партнёр встраивает на сайт, два идентификатора: UUID проекта и домен. Проект определяет, что показывать. Домен — как.

Виджет поднимается, скачивает по домену свой JSON с параметрами и отдаёт его в конструктор корневого класса. Всё. Параметры мержатся поверх дефолтов, логотип и CSS подхватываются по ссылкам из того же ответа:

const branding = await loadBranding(window.location.hostname);

createWidget({
  ...defaults,
  logo: branding.logo_url,
  ...branding.widget_params,
});
Один проект, множество брендирований
Один проект, множество брендирований

Тот же проект, те же точки камер. Меняется только домен — и вместе с ним всё, что вокруг картинки.

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

server {
    server_name *.widget.example.com;
    server_name  widget.example.com;

    root /var/www/branded-widget;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
        add_header Access-Control-Allow-Origin *;
    }
}

Wildcard в server_name, wildcard-сертификат через DNS-01 (с базовым именем в SAN — *. его само не покрывает). Новый клиент = новая строка в базе. Ни репозитория, ни пайплайна, ни выписывания сертификата, ни правки Nginx.

Сегодня в проде больше восьмисот брендингов, живущих на четырёх wildcard-доменах. Четыре сертификата на восемьсот с лишним клиентских поддоменов.

Форки не исчезли — они схлопнулись

Ноль форков мы не получили и не стремились. Есть один монорепозиторий, в котором лежит дюжина приложений: базовые витрины, брендированные сборки, стейдж. Дочерние пайплайны запускаются по changes: — тронул папку, собралась только она.

Плюс несколько клиентов — меньше процента — требуют кастомного JavaScript, который конфигом не выразить: специфичное позиционирование относительно чужого оверлея, нестандартная обработка ошибок. Для них остался маленький реестр в той же брендированной сборке. Это нормальная цена: 99% клиентов живут в базе, 1% — в репозитории, и никто не форкает ядро.

Было около сотни репозиториев. Стал один.


Наблюдаемость: кто на какой версии

Когда версий было сто, вопрос «сколько клиентов на уязвимой версии» был исследовательским проектом. Закрыли его так: виджет представляется в заголовке запроса.

private get headers() {
  return { Accept: `application/json;widget-version=${process.env.WIDGET_VERSION}` };
}

Почему версия в Accept, а не в собственном заголовке: Accept входит в список CORS-safelisted заголовков и не требует preflight. Свой X-Widget-Version потребовал бы лишний OPTIONS с чужого домена.

Дальше парсим заголовок при сборе логов — и получаем распределение версий в активном трафике, по доменам, с которых к нам приходят. Видно, кто отстал; видно, разъезжается ли парк после релиза. Стоило это одного заголовка и одного правила парсинга.


Рецепт, если у вас то же самое

Минимальный путь. По нашему опыту, на прототип уходит несколько дней, на боевую версию — неделя-две.

1. Найдите исходник контракта — тип в TypeScript, dataclass в Python, структура в Go. Скорее всего, он уже есть. Если конфигурация описана только словарём и документацией — начинать надо с типа, всё остальное бессмысленно.

2. Сгенерируйте из него схему в CI. Для Python — MyModel.model_json_schema(), для Go — invopop/jsonschema.

3. Положите схему в версионированное хранилище. latest/, versions/<tag>/, versions.json, рядом meta.json с коммитом и пайплайном. База не нужна.

4. Обновляйте latest только если тег совпадает с dist-tag в npm. Backport — с явным --tag. Иначе хотфикс в старую линию сломает вам редактор.

5. Подключите Monaco. Ссылка на схему в setDiagnosticsOptions — и вы получаете автокомплит, документацию и валидацию бесплатно. schemaValidation: 'error', чтобы нарушения схемы блокировали сохранение.

6. Поставьте рядом iframe. Предпросмотр важнее, чем кажется: он убирает раунды согласования, а они в старом процессе стоили от одного до пяти дней каждый.

7. Если пользователи не технические — соберите форму из той же схемы. Типы уже описаны, маппинг в контролы механический. Форма обновится сама при следующем релизе.

8. Разнесите «что показать» и «как показать». Конфигурация должна приезжать данными, а не быть зашитой в артефакт сборки. Дальше wildcard-домен и wildcard-сертификат закрывают инфраструктуру целиком.

9. Пометьте версию клиента в запросе. Один заголовок — и вы видите парк версий в проде.


Цифры

Что видно в проде прямо сейчас:

Брендингов

800+

В проде

3 года, 20–45 новых брендингов в месяц без пауз

Wildcard-доменов

4

Параметров в схеме

под сотню верхнего уровня, несколько сотен с вложенностью

Версий схемы в S3

полторы сотни

Версий пакета в npm

четыре с половиной сотни

Клиентов с кастомным JS

меньше 1%

Репозиториев было / стало

около сотни / 1

Новые брендинги по месяцам: три года без пауз, 20–45 в месяц
Новые брендинги по месяцам: три года без пауз, 20–45 в месяц

Что оценивает команда:

  • подключение партнёра: с двух недель до пары дней;

  • ошибки кастомизации из-за опечаток исчезли — их ловит редактор;

  • около трети времени разработки вернулось на разработку;

  • решение окупилось на втором десятке брендингов, сделано восемьсот.

Самое интересное — что именно крутят

Из восьмисот брендингов параметры трогают примерно треть: остальным хватило логотипа, названия, языка и нескольких строк CSS.

Среди тех, кто крутит, в среднем меняют три ключа, рекордсмен — восемнадцать. Топ:

localeOverrides                  ~200
colors                           ~190
isHiddenFurnitureSwitchVisible   ~110
controls                          ~50
scales                            ~20
tabs                              ~20
lazyLoad                          ~20
uiIcons                           ~15
Частота параметров: три первые ручки покрывают 95%, дальше длинный хвост
Частота параметров: три первые ручки покрывают 95%, дальше длинный хвост

Три первые ручки покрывают 95% конфигураций. Хвост длинный и тонкий: из сотни доступных параметров за три года хоть раз тронули только половину, и половина из этой половины использована один-два раза.

Вывод, который я бы сделал на месте того, кто это начинает: мы не угадали. Мы бы точно не поставили на первое место переопределение локалей — казалось, что главным будет цвет. Поэтому подход «сгенерируем схему из типа и покажем всё» оказался правильнее, чем «спроектируем красивую форму под десять самых нужных настроек»: форму мы бы спроектировали не под те настройки. А теперь, когда форма строится из схемы автоматически, вопрос «под какие» вообще не стоит.


Что забрать с собой

Форки — симптом. Болезнь — отсутствие контракта конфигурации. Пока его нет, каждый нестандартный клиент честно порождает копию кодовой базы, и это ещё рациональное поведение.

Исходник контракта у вас, скорее всего, уже есть. Он в типах. Задача не создать его, а протащить до интерфейса нетехнического пользователя, не заведя по дороге новой рутины. TypeScript → JSON Schema → S3 → Monaco: после написанного руками TSDoc контракт распространяется по цепочке сам.

Схема — это не только валидация. Это документация, автокомплит и готовое описание интерфейса. Одна схема кормит редактор, форму и подсказки — и обновляется одним тегом в CI.

Вопросы и критику — в комментарии. Особенно интересно услышать тех, кто решал ту же задачу иначе.