Skip to content

PlatformContext ​

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

Прочитайте это, прежде чем использовать его в федеративном модуле

PlatformContext передаётся через React Context, а React Context не пересекает границу Module Federation — это работало бы, только если бы @tv/extension-sdk был общим синглтоном и в хосте, и в каждом remote, а сегодня это не так.

Поэтому внутри федеративного модуля usePlatformContext(), useBuilding(), useCurrentUser() и useOptionalBuilding() бросают исключение — даже при том, что Building OS действительно отрисовал <PlatformProvider> вокруг вашего Shell. На этапе сборки ничего не предупредит: вы узнаете об этом при монтировании.

Где выполняется кодЧто использовать
Федеративный модуль (ваш ./Shell и всё под ним)useSelectedBuildingId(), useBuildingSelection() из @tv/extension-sdk/react; вне React — readBuildingSelection(), subscribeBuildingSelection() из @tv/extension-sdk/context; для собственных запросов к API — getAccessToken() / authorizationHeader() из @tv/extension-sdk/context (ниже); для чтения и записи графа — createBuildingGraphClient() из @tv/extension-sdk/graph поверх createPlatformApiClient() из @tv/extension-sdk/context; для событий платформы — usePlatformEvent() из @tv/extension-sdk/react (События); последним двум нужен SDK 1.18.0 или новее
Код внутри бандла оболочкихуки PlatformContext, описанные ниже
Ваши тестыхуки ниже — <PlatformProvider> вы отрисовываете сами, поэтому границы не существует

Дальше страница описывает сам объект контекста. Всё это верно для кода внутри оболочки и для тестов — примеры с хуками относятся именно к этим двум местам.

Форма ​

ts
interface PlatformContext {
  user: PlatformUser;                  // кто вошёл + его роли в realm
  organization: PlatformOrganization;  // организация + её лицензированные модули
  building: PlatformBuilding | null;   // активное здание — null на уровне организации
  locale: string;                      // "en", "ru", "hi"
  theme: 'light' | 'dark';
  api: PlatformApiClient;              // уже аутентифицированный HTTP-клиент в пределах организации
  eventBus: EventBusClient;            // публикация / подписка на события платформы
}

Обратите внимание: building может быть null — пользователь может находиться на уровне организации, не выбрав здание. Используйте useBuilding(), когда маршрут действительно требует здания, и useOptionalBuilding(), когда нет.

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

Хуки ​

tsx
import {
  usePlatformContext,
  useBuilding,
  useCurrentUser,
  useOptionalBuilding,
} from '@tv/extension-sdk/react';

function MyComponent() {
  const ctx = usePlatformContext();     // весь контекст; бросает исключение вне провайдера
  const building = useBuilding();        // бросает исключение, если здание не выбрано
  const user = useCurrentUser();         // вошедший пользователь
  const maybe = useOptionalBuilding();   // null вместо исключения
}

Ререндеры — сколько стоит подписка ​

PlatformContext — настоящий React-контекст, поэтому применимо стандартное опасение: при смене значения провайдера перерисовываются все подписчики. Здесь оно ограничено самой конструкцией:

  • Identity значения стабильна — оболочка держит один объект контекста и заменяет его только при смене вошедшего пользователя, выбранного здания, локали или темы. Всё это редкие, инициированные пользователем моменты; первые три и так обесценивают всё, что модуль отрисовал.
  • Переключение темы — точечный патч, а не пересборка. Оболочка обновляет поле theme в объекте контекста, сохраняя всё остальное: подписчики перерисовываются один раз с новым значением, соединения не рвутся. Модулям, которые красятся через CSS-переменные, ctx.theme вообще не нужен; читайте его только для поверхностей, рисуемых из JS (canvas, материалы Three.js).
  • Высокочастотные данные через значение контекста не текут. Телеметрия и события платформы приходят в обработчик, который вы передаёте в usePlatformEvent() из @tv/extension-sdk/react, а вне React — в subscribePlatformEvent() из @tv/extension-sdk/context (События) — шквал событий перерисует только компоненты, чьё состояние вы сами обновили в обработчике, а не всех подписчиков контекста.
  • У usePlatformEvent() нет списка зависимостей. Он всегда вызывает последний переданный обработчик и переподписывается только при смене типа события, поэтому обработчик, объявленный прямо в компоненте, ничего не «дёргает».
  • api сохраняет identity при патче темы и заменяется только при полной пересборке, поэтому указывать его в зависимостях хуков корректно и ничего не «дёргает».

Если профилировщик показывает шторм ререндеров в вашем модуле, причина — в вашем собственном управлении состоянием ниже обработчика, а не в контексте.

Клиент api ​

ctx.api — это HTTP-клиент, который уже несёт аутентификацию пользователя и ограничен активной организацией. Вы никогда не видите токен.

В федеративном модуле ctx.api, как и весь контекст, недоступен. Там вызывайте API сами, как показано в разделе Вызов API из модуля.

ts
// /spaces отвечает страницей: { data, total, limit, offset }
const spaces = await ctx.api.get<{ data: Space[]; total: number }>(`/api/v1/buildings/${building.id}/spaces`);
await ctx.api.post(`/api/v1/buildings/${building.id}/faults`, dto);

Доступны get / post / put / patch / delete, каждый принимает необязательный { headers, query, signal }.

Почему это важно: один и тот же компонент работает и в вашей песочнице, и у клиента в продакшене, потому что единственное, что между ними меняется — аутентификация и базовый URL, — поставляет платформа, а не ваш код.

Чтение данных организации ​

ctx.organization.activeModuleIds показывает, на какие модули у организации есть лицензия. Используйте это для мягкой деградации UI, а не как средство безопасности: проверка на клиенте — удобство, а не граница. Граница должна быть на сервере, и строить её вам. @RequiresLicense() из @tv/extension-sdk/nestjs только записывает в метаданные, какая лицензия нужна маршруту; пока при поступлении запроса её никто не проверяет.

tsx
const { organization } = usePlatformContext();
const hasCafm = organization.activeModuleIds?.includes('@tv/module-cafm');

Как следить за выбранным зданием из модуля ​

В Building OS ровно один селектор здания — тот, что в шапке, — и до федеративных модулей он доходит через глобальный канал, а не через React Context. Поэтому следующее работает по обе стороны границы:

tsx
import { useSelectedBuildingId, useBuildingSelection } from '@tv/extension-sdk/react';

const buildingId = useSelectedBuildingId();
// null = здание ещё не выбрано: оболочка грузится или вы работаете автономно.

const { buildingId, buildingIds, isAllBuildings, buildings } = useBuildingSelection();
// когда пользователь выбрал несколько зданий, а не одно

Вне React ту же задачу без привязки к фреймворку решают readBuildingSelection() и subscribeBuildingSelection() из @tv/extension-sdk/context.

Два правила, которые не про стиль:

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

Вызов API из модуля ​

Модуль подписывает свои запросы токеном вошедшего пользователя, который оболочка выдаёт через @tv/extension-sdk/context (SDK 1.15.0 и новее):

ts
import { authorizationHeader } from '@tv/extension-sdk/context';

async function fetchSpaces(buildingId: string) {
  const res = await fetch(`/api/v1/buildings/${encodeURIComponent(buildingId)}/spaces`, {
    headers: authorizationHeader(), // { Authorization: 'Bearer …' } или {}, если вход не выполнен
  });
  if (!res.ok) throw new Error(`spaces: HTTP ${res.status}`);
  return (await res.json()) as { data: unknown[]; total: number; limit: number; offset: number };
}
  • Вызывайте на каждый запрос и не кэшируйте токен. Оболочка незаметно обновляет его, и сохранённая копия устаревает. getAccessToken() возвращает сам токен (или null), если нужен не заголовок.
  • Используйте относительный путь /api/…. Везде это тот же origin, что и у оболочки: в продакшене веб-сервер оболочки передаёт /api/ в tv-api, а в песочнице её хост направляет /api в собственный tv-api песочницы. Не прописывайте адрес tv-api жёстко.
  • Списки возвращают страницу, а не массив. /spaces и /elements отвечают { data, total, limit, offset } (/elements считает total только на первой странице); /storeys отвечает обычным массивом. Типизированный клиент createBuildingGraphClient() из @tv/extension-sdk/graph описывает их как Page<T> и ElementsPage<T>.
  • Вне оболочки токена нет. В pnpm dev getAccessToken() равен null, а authorizationHeader() — {}, поэтому реальные запросы получают 401; подменяйте их или запускайте модуль в песочной оболочке.
  • Если пришёл 401: resolveAccessToken() возвращает { token, source }. source: 'platform' означает, что токен дала собственная функция оболочки; 'oidc-storage' — запасной путь для оболочек, в которых этой функции ещё нет.
  • Никогда не читайте localStorage['access_token'] и хранилище OIDC-библиотеки напрямую. Первое — устаревшая копия, которая уходит и уже выключена в песочных оболочках; второе — внутренний формат.

Начиная с SDK 1.18.0 это делает createPlatformApiClient(), а типизированный клиент графа работает поверх него. createPlatformApiClient() и PlatformApiError имеют уровень @experimental: параметры и поля ошибки могут измениться, а возвращаемый PlatformApiClient — стабильный контракт (Уровни стабильности):

ts
import { createPlatformApiClient } from '@tv/extension-sdk/context';
import { createBuildingGraphClient } from '@tv/extension-sdk/graph';

const api = createPlatformApiClient();        // токен на каждый запрос, относительный путь /api
const graph = createBuildingGraphClient(api);

const building = await graph.buildings.get(buildingId);            // этажи, счётчики, тип
const spaces = await graph.spaces.list(buildingId, { limit: 50 });  // { data, total, limit, offset }

Любой другой эндпоинт — api.get<T>('/api/v1/…', { query }). Ответ не из 2xx выбрасывает PlatformApiError со status и телом ошибки tv-api в body. Типы ответов следуют OpenAPI-документу tv-api, который SDK поставляет как openapi.snapshot.json.

Не обходите его стороной ​

Если вы замечаете, что вручную разбираете ключи localStorage оболочки, собираете URL Keycloak или жёстко прописываете https://tv-api… — остановитесь. Такие пути не переживают переход между организациями и находятся вне того, что поддерживает платформа: когда оболочка меняет ключ хранилища, SDK это поглощает, а самописный парсер ломается молча.

Для каждого из этих случаев есть поддерживаемый вызов: API выбора здания выше, getAccessToken() из @tv/extension-sdk/context для аутентификации, resolveLocale() из @tv/extension-sdk/i18n для языка и createBuildingGraphClient() из @tv/extension-sdk/graph для графа здания.

→ Далее: Федерация и оболочка

Создано на платформе Tango Vision. Вопросы? developers@tango.vision