Статья

Веб-модули в Flutter-клиенте для FastEn и как мы обновляем их на 5-и платформах сразу

Mini-Apps в Server-Driven UI клиенте фреймворка FastEn. Веб-модули внутри Flutter-клиента, обмен данными через мост, авторизация статики через Nginx и обновления без выпуска новой версии приложения.

1. Введение. Ограничения Server-Driven UI

В основе некоторой части нашей заказной разработки и собственного SaaS-ERP Boost-X лежит FastEn — наш RAD-фреймворк, платформа для быстрой сборки корпоративных систем. Его ядро построено на концепции Server-Driven UI (SDUI): описание интерфейса живёт на бэкенде и приезжает на клиент не заранее, в составе обновления, а в момент, когда пользователь открывает нужный раздел (или при старте приложения). Тонкий кроссплатформенный клиент написан на Flutter, сервер под капотом — FastAPI.

Из единой кодовой базы мы раскатываем функционал сразу на 5 платформ: нативные мобильные приложения (iOS, Android), десктопные клиенты (Windows, macOS) и Web. По механике это похоже на обычный веб: клиент запрашивает «страницу», сервер отдаёт её актуальную версию. Обновили сервер — клиенты получили свежие версии экранов при следующем запросе, без установки новой версии приложения. Для 90% типовых B2B-задач SDUI сокращает Time-to-Market в разы.

Но рано или поздно эта схема упирается в конечность вариаций компонентов SDUI.

Что делать, если логисту в приложении нужна интерактивная карта с динамической кластеризацией адресов и кастомными пинами? Начальнику цеха — планировщик смен в виде диаграммы Ганта с Drag-and-Drop? Менеджеру B2B-портала — интерфейс для оформления акций?

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

Решать «в лоб» — писать сложный кастомный UI нативно или собирать набор Flutter-плагинов и добиваться их стабильной работы на всех 5 операционных системах — значит потерять и скорость кроссплатформенной разработки, и бюджет.

Резонный вопрос: а почему бы тогда не отказаться от нативного клиента вообще и не написать всю ERP как обычное веб-приложение (SPA)? Потому что это означает отказ от нативной производительности, плавности интерфейса, интеграции с ОС и железом (например, сканерами штрих-кодов), к которым привыкли пользователи мобилок и десктопов. Жертвовать этим для всей системы ради 10% кастомных экранов мы не захотели.

Решение: Mini-Apps как расширяемый UI-контрол

Нам был нужен управляемый «аварийный выход» — механизм, который решает три задачи: использовать готовые JS-библиотеки (React, Vue, Mapbox, редакторы) для нестандартного интерактива, доставлять такие модули без пересборки клиентов и модерации App Store / Google Play — и при этом сохранить контроль над доступом к коду и данным.

Так в платформе FastEn появился движок Mini-Apps — архитектура, встраивающая внешние веб-приложения внутрь нативного клиента. Не «глупые» веб-страницы в iframe, а полноправные модули бизнес-логики, которые обмениваются данными с нативными формами и подчиняются общим правилам безопасности.

Архитектура решает пять практических задач:

  • 1 код — 5 платформ: веб-модуль пишется один раз и работает на iOS, Android, macOS, Windows и Web через наш универсальный мост.
  • Декларативный in/out маппинг: бэкенд сам описывает, как данные из мини-аппа попадают в нативную форму, — без хардкода на клиентах.
  • Delivery: новые фичи и хотфиксы доезжают до пользователей без ревью в сторах, за счёт кэш-контроля Nginx.
  • Безопасность: двухуровневая схема с Nginx Gatekeeper и инъекцией токенов напрямую в память (In-Memory Token Injection) — статика и токены не утекают через URL.
  • Сессии: истекшая авторизация обновляется в фоне, без «белых экранов».

2. Единый JS-API поверх разных веб-движков: универсальный «мост»

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

Наш Flutter-клиент работает на 5 платформах:

  • На iOS / macOS сообщения ходят через window.webkit.messageHandlers.
  • На Android — через JavascriptInterface.
  • На Windows (десктоп) под капотом WebView2.
  • А в Web-версии приложения мини-апп открывается в iframe, и общение идёт через window.postMessage.

Заставлять фронтенд-разработчика мини-аппа (нашу команду или внешних партнёров) поддерживать все четыре протокола в своём JS/TS-коде — значит размазать эту сложность по каждому модулю и плодить технический долг.

Инъекция полифилов на уровне хост-приложения

Логику определения платформы и выбора транспорта мы перенесли в нативный контроллер. Перед загрузкой страницы мини-аппа Flutter-клиент встраивает в неё “UserScript”, который создаёт единый глобальный объект window.FastEnBridge и транслирует его вызовы в нативный интерфейс текущей ОС.

Принцип «тонкого клиента» и SDK

Само мини-приложение при этом ничего не знает о среде, в которой запущено. Веб-разработчик берёт привычный стек (React, Vue, просто JS/TS), подключает наш легковесный NPM-пакет fast-en-bridge и вызывает bridge.sendToHost({ type: 'submit', payload: data }). Мост сам сериализует данные (это особенно критично для строгих WebKit-окружений на iOS/macOS) и доставляет их нативному клиенту.

В результате UI-модуль пишется один раз (Write Once, Run Everywhere) и работает во всех наших нативных и десктопных клиентах.

Осталось разобраться, что нативному клиенту делать с данными, которые прилетают из мини-аппа.

3. In/out мэппинг данных: мини-апп ведёт себя как обычное поле формы

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

Нам же было нужно, чтобы мини-апп вёл себя как обычный нативный UI-контрол — как текстовое поле или выпадающий список. Взял данные из текущей формы пользователя (например, из черновика заказа), дал их изменить в своём кастомном интерфейсе, вернул результат обратно в форму.

FastEn — Server-Driven UI фреймворк, поэтому и эту задачу мы отдали серверу, таким образом исключив хардкод на клиентах. Логика передачи данных диктуется бэкендом через механизм in/out мэппинга.

Сценарий: карта в карточке заказа

Логист в десктопном приложении Boost-X открывает карточку заказа. В карточке есть кнопка «Указать на карте», по клику должен открыться мини-апп с картой.

Вместе со структурой формы бэкенд присылает реестр мини-аппов (объект mini_apps в JSON-ответе), где для этой кнопки описаны правила обмена данными:

// Пример конфигурации мини-аппа, приходящей от бэкенда FastEn
"mini_apps": {
  "pickup_location_picker": {
    "url": "https://mini-apps.my-domain.com/map-picker",
    "input_mapping": {
      "initial_lat": "order.pickup.lat",
      "initial_lng": "order.pickup.lng"
    },
    "output_mapping": {
      "latitude": "order.pickup.lat",
      "full_address": "order.pickup.address"
    }
  }
}
  1. Input Mapping (Сервер -> Нативный клиент -> Веб): Бэкенд декларирует правило, например "initial_lat": "order.pickup.lat". Когда логист нажимает кнопку, Flutter-клиент берёт текущее значение из локального стейта формы по пути order.pickup.lat и упаковывает его в стартовый Payload. Через наш Универсальный Мост эти данные инжектируются в JS-контекст стартующего мини-аппа — карта сразу открывается на нужных координатах.

  2. Output Mapping (Веб -> Нативный клиент -> Сервер): Логист передвигает пин и нажимает «Подтвердить». Мини-апп понятия не имеет, как устроена форма заказа в Boost-X, — он просто отправляет в Мост абстрактный JSON: {"latitude": 55.75, "full_address": "г. Москва, ..."}. Flutter-клиент перехватывает сообщение, сверяется с серверным правилом output_mapping ("latitude" -> "order.pickup.lat", "full_address" -> "order.pickup.address") и обновляет свои локальные поля. Пользователь сразу видит новый адрес и координаты в форме заказа, без перезагрузки экрана.

Что это даёт

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

Остался вопрос доставки: как обновлённый код самих мини-аппов попадает на устройства.

4. Как обновлять Mini-Apps без релиза клиента: ETag

Известная боль нативной разработки — цикл релизов. Нашли баг (хорошо, если не критический) — соберите билд, отправьте на модерацию в App Store и Google Play (от нескольких часов до пары дней), а потом надейтесь, что пользователи обновятся. С десктопом не сильно легче: новые инсталляторы или же своя система автообновления.

Для кастомных UI-модулей архитектура Mini-Apps снимает эту проблему: их жизненный цикл отвязан от релизного цикла приложения-хоста (Boost-X).

Есть нюанс: WebView и встроенные браузеры очень агрессивно кэшируют статику (HTML, JS, CSS). Оставить как есть — и пользователи будут открывать старую, закэшированную версию модуля.

Управление кэшем на уровне Nginx

Чтобы пользователи всегда получали свежую версию мини-аппа, а запуск при этом оставался быстрым, контроль кэширования мы перенесли на веб-сервер. Для локации /apps/ Nginx принудительно отдаёт Cache-Control: no-cache, must-revalidate.

В связке с ETag (хешем версии файла) механика получается такая:

  1. Пользователь открывает форму в Boost-X и вызывает мини-апп. WebView не качает файлы заново, а делает запрос к Nginx: «У меня версия X, она не изменилась?».
  2. Если обновлений не было, Nginx отвечает 304 Not Modified, и WebView достаёт тяжёлые скрипты и стили из своего кэша. Визуально запуск мгновенный.
  3. Как только фронтенд-команда деплоит новую версию (просто перезаписывает файлы бандла на сервере), ETag меняется — и при следующем открытии любой пользователь получит 200 OK и свежий код.

Фронтенд-разработка: Как отлаживать Mini-Apps?

Может показаться, что разработка такого модуля — это мучение с постоянными деплоями. На деле мы заложили специальный Proxy Mode для локальной отладки. Если нативное приложение FastEn запущено в Debug-режиме, разработчик может переопределить URL мини-аппа на http://localhost:3000. Фронтендер поднимает локальный dev-сервер (например, Vite) и видит изменения в коде мини-аппа прямо на экране приложения в реальном времени. Пересобирать нативный клиент ради проверки вёрстки не нужно.

Что это даёт

Новый функционал, изменения логики интерактивных компонентов и хотфиксы доезжают на все 5 платформ синхронно; Time-to-Market для таких правок сократился с дней и недель до минут. Не нужно менять пути к файлам, выдумывать версионирование в URL или выпускать минорные апдейты самого FastEn-клиента.

Правда, раздавая файлы приложения с сервера, мы сами себе создаём вопрос, который первым задаст любая служба безопасности: а как сделать так, чтобы эту статику не мог скачать никто, кроме авторизованных пользователей?

5. Два уровня авторизации (для статики и для логики)

Веб-модули в корпоративном приложении — всегда под прицелом ИБ. Классических уязвимостей здесь две:

  1. Открытый контур: статические файлы (JS/HTML) лежат на публичном сервере — код можно выкачать, изучить бизнес-логику, найти точки входа в API.
  2. Утечка токенов: токен для API-запросов передаётся в веб-вью через URL-параметры (и оседает в логах роутеров) или хранится в уязвимом LocalStorage.

Авторизация у нас разделена на два независимых слоя: доступ к файлам и доступ к данным.

Уровень 1: Транспорт (доступ к файлам через Nginx Gatekeeper)

Право просто скачать интерфейс мини-аппа должно быть только у авторизованных пользователей приложения. Нагружать Python-бэкенд раздачей статики не хотелось, поэтому шлюзом аутентификации стал сам Nginx — через модуль auth_request.

Прежде чем отдать ресурс из /apps/, Nginx делает запрос к FastAPI. Сессия активна (HTTP 200) — файл отдается, нет — 401 Unauthorized.

Мобильные WebView непредсказуемо работают с кастомными HTTP-заголовками при автоматической загрузке ресурсов, поэтому на этом уровне мы используем исключительно Cookies. Ещё до старта загрузки нативный клиент (Flutter) принудительно устанавливает Secure HttpOnly куку для домена мини-приложений.

# Упрощенный пример участка конфигурации Nginx
location /apps/ {
    # 1. Запрос к внутреннему сервису авторизации
    auth_request /internal/auth/verify;

    # 2. Раздача статики только в случае успеха (HTTP 200)
    alias /var/www/storage/mini-apps/;

    # 3. Управление кэшем для мгновенных обновлений
    add_header Cache-Control "no-cache, must-revalidate";
}

location = /internal/auth/verify {
    internal;
    proxy_pass http://fastapi_backend/api/v1/auth/verify-cookie;
    proxy_pass_request_body off;
    proxy_set_header Cookie $http_cookie; # Проброс Secure Cookie
}

Уровень 2: Прикладной (доступ к данным через JS-инъекцию)

Скачивание файлов защитили — теперь нужно безопасно передать мини-аппу токен для работы с API. От передачи чувствительных данных в URL и от хранения их на стороне веб-клиента мы отказались полностью.

Принцип такой: у мини-приложения нет собственного состояния авторизации. После того как страница загружена через Nginx, нативный клиент берёт API-токен пользователя и инжектирует его напрямую в оперативную память JS-движка через защищённый канал Универсального Моста (вызовом window.initFastEnApp(payload)).

Что в итоге по векторам атак

  • Корпоративный контур закрыт: статику нельзя выкачать извне, мини-приложение не откроется в обычном браузере по украденной ссылке — без активной сессии в нативном клиенте Boost-X Nginx его просто не отдаст.
  • Токены не светятся в истории браузера, URL-адресах и на диске устройства.
  • При закрытии нативной шторки контекст мини-аппа уничтожается вместе с токенами в оперативной памяти.

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

Но загрузить страницу — половина дела. Нужно ещё, чтобы нативное приложение и веб-модуль стартовали синхронно — без «белого экрана» и потери данных.

6. Синхронный старт и Silent Refresh

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

Синхронный старт

При инициализации мини-аппа возникает классическая гонка: кто загрузится первым? Отправить начальные данные в веб-контекст до полной загрузки страницы — данные потеряются. «Угадывать» задержки таймерами — пользователи медленных устройств будут смотреть на пустые экраны.

Вместо угадывания — жёсткая стейт-машина:

  1. Flutter-клиент инициирует открытие мини-аппа, но перекрывает его нативным индикатором загрузки.
  2. Браузер качает статику. Мини-апп загружается «чистым» — он ещё не знает ни контекста, ни кто его открыл.
  3. Как только DOM-дерево готово принимать данные, JS-код мини-аппа отправляет через Универсальный Мост сигнал system:ready.
  4. Получив сигнал, нативный клиент инжектирует в веб-контекст стартовый Payload: данные формы, языковые настройки, параметры темы для синхронизации дизайна и API-токен прикладного уровня. Для этого вызывается глобальная функция window.initFastEnApp(data).
  5. Мини-апп рендерит UI с полученными данными — и только после этого нативный индикатор загрузки скрывается. Никаких «морганий» и пустых экранов: лоадер уходит, когда интерфейс уже инициализирован.

Тихое обновление токенов

Вторая проблема — устаревание токенов доступа. В классическом веб-приложении протухший токен (401 Unauthorized) означает переадресацию на страницу логина. Теперь представьте: логист полчаса заполняет форму заказа в Boost-X, открывает мини-апп с картой — и в этот момент сессия истекает. Экран логина внутри виджета и потерянный черновик ему не простят.

Поэтому мини-апп за авторизацию не отвечает вовсе. Если API-запрос внутри него возвращает 401, включается механизм тихого обновления:

  1. Мини-апп ставит все текущие и исходящие сетевые запросы на «паузу».
  2. Через Мост уходит сигнал token_refresh хост-приложению.
  3. Нативный Flutter-клиент в фоне обращается к серверу аутентификации, обновляет ключи (Refresh Token), переписывает Cookies и инжектирует новый Access-токен обратно в JS-контекст.
  4. Получив новый ключ, мини-апп подменяет его в заголовках и автоматически повторяет «упавшие» запросы.

Пользователь этого процесса не замечает: ни экранов логина, ни сброшенных данных.

7. Итоги: SDUI для 90% задач, защищённые веб-модули — для остальных 10%

Архитектура Mini-Apps позволила нам не выбирать между SDUI и вебом, а взять сильные стороны обоих. Для 90% задач работает Server-Driven UI: стандартные списки, таблицы и бизнес-формы генерируются сразу для 5 платформ (iOS, Android, Windows, macOS, Web) из единой кодовой базы. Для оставшихся 10% — карт, сложных визуализаторов, редакторов — подключаются защищённые веб-модули.

Что дали Универсальный Мост, декларативный маппинг и шлюз аутентификации Nginx в сумме:

  1. Независимость от сторов и релизных циклов. Логика сложных компонентов обновляется на сервере и сразу доставляется на все устройства.
  2. Безопасность. Исходный код изолирован, статика отдаётся только авторизованным сессиям, токены живут в защищённом хранилище.
  3. Цельный UX. Пользователь нативного клиента Boost-X не догадывается, что часть функционала отрисована веб-движком.

Дальше возникает отдельный вопрос: как сохранить бизнес-данные, когда пользователь нажимает «Сохранить» в мобильном приложении или мини-аппе? Ни одно событие не должно потеряться даже при падении серверов и обрывах сети — и при этом микросервис версионирования (истории данных) не должен тормозить основную базу.

Об этом — в одной из следующих статей: разберём под капотом Систему версионирования Boost-X, построенную на архитектурном паттерне Transactional Outbox.


Часто задаваемые вопросы (FAQ)

Отвечаем на главные архитектурные и технические вопросы о гибридной разработке на FastEn:

Как ускорить разработку B2B-системы сразу под iOS, Android, Windows, macOS и Web и не раздувать штат❓

Использовать комбинацию Server-Driven UI (SDUI) для 90% стандартных форм и архитектуру Mini-Apps для 10% сложного кастомного интерактива. Это позволяет удерживать единую кодовую базу на Flutter для всех 5 платформ, без отдельной реализации под каждую ОС.

Как обновлять бизнес-логику и чинить хотфиксы в мобильном приложении в обход модерации App Store и Google Play❓

Выносить сложный или часто меняющийся UI в архитектуру Mini-Apps. Изменения в коде веб-модуля становятся доступны пользователям с сервера после проверки актуальности через ETag — без выпуска апдейта нативного приложения и без ожидания проверки сторов.

Безопасна ли WebView-архитектура в Enterprise и как защитить статику и токены от утечек❓

За счёт архитектуры Zero-Trust и двухуровневой авторизации (Split Auth). Статические файлы закрываются шлюзом Nginx с проверкой сессии (auth_request), а чувствительные API-токены инжектируются напрямую в оперативную память JS-движка через нативный мост — не передавая токены через URL и не сохраняя их в LocalStorage.

Как избавить гибридное приложение на Flutter от белых экранов и вылетов на страницу авторизации❓

Использовать синхронный старт (протокол Handshake V2) и механизмы Silent Refresh. Нативный индикатор загрузки держится до полной готовности веб-модуля (сигнал system:ready), а при истечении токена нативный клиент обновляет сессию в фоне, не сбрасывая введённые пользователем данные.

Смогут ли наши внутренние веб-разработчики дорабатывать Mini-Apps без знания Flutter и языка Dart❓

Да. Нативный клиент FastEn предоставляет единый мост (fast-en-bridge), поэтому веб-модули пишутся на любом стандартном стеке (React, Vue, Vanilla JS) по принципу Write Once, Run Everywhere. Любая веб-студия или ваш внутренний IT-отдел сможет создавать новые фичи без погружения в мобильную разработку.


Проектируете архитектуру для сложного B2B/Enterprise продукта?

Если стоите перед выбором между нативной разработкой, PWA и кроссплатформенными решениями — запишитесь на экспресс-аудит. Архитекторы DEV-STACK изучат ваши вводные и честно скажут, где гибридный подход и Server-Driven UI сэкономят вам годы разработки и миллионы бюджета, а где объективно лучше классический натив.

→Заявка

Проверить проект

Запишитесь на экспресс-оценку. Наш архитектор изучит вводные и даст обратную связь. Это бесплатно.