Skip to content

Ваш первый модуль ​

Мы соберём минимальный модуль, который монтирует страницу в оболочке Building OS и читает данные выбранного здания из API платформы. Бюджет: 30 минут.

Код на этой странице проверен на свежем каркасе --external из SDK 1.15.0: если вставлять его по порядку, pnpm typecheck, pnpm test и pnpm build проходят, так что копируйте его как есть.

1. Создание каркаса ​

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

bash
npx @tv/extension-sdk init-module hello \
  --name="Hello Module" \
  --category=operations \
  --external --scope=acme --author="Acme Ltd"
cd tv-module-hello
pnpm install

В --scope укажите npm-область вашей компании, в --author — её название: идентификатор модуля станет @acme/module-hello, а автором в манифесте будет Acme Ltd. Без этих флагов модуль получит идентификатор @tv/module-hello и автора «Tango Vision», а @tv — наша область. SDK 1.15.0 принимает --author, хотя в --help этого флага пока нет.

--external — флаг для вас

Без него каркас рассчитан на репозитории внутри организации Tango Vision: сгенерированный ci.yml просит runs-on: self-hosted и вызывает составные actions из закрытого репозитория tangovision/infrastructure, поэтому в вашем репозитории задание либо падает на старте с «repository not found», либо стоит в очереди, пока GitHub не отменит его через 24 часа. --external генерирует CI на ubuntu-latest с pnpm/action-setup@v4, закреплённым на платформенной версии pnpm (10.18.2), с вашим собственным секретом TV_NPM_TOKEN в npmrc в пределах задания и с AGENTS.md без внутреннего блока правил платформы. Код, который выполняет оболочка, в обоих вариантах одинаков.

Начиная с 1.14.0 — --external появился в @tv/extension-sdk 1.14.0, но создавайте каркас версией 1.15.1 или новее: более ранние версии создают проекты с известными ошибками, а проект версии 1.14.0 не собирается вовсе ("useQuery" is not exported). В 1.13.0 этого флага нет: обновите SDK, а не обходите его. Ошибки каждой версии и то, как поправить созданный ею модуль, — в разделе Известные проблемы по версиям SDK.

Какие файлы он пишет (CLI выводит каждый полным путём):

module-manifest.json          vite.config.ts
src/Shell.tsx                 package.json
README.md                     pnpm-workspace.yaml
AGENTS.md                     CLAUDE.md
CHANGELOG.md                  src/i18n.ts
src/locales/en.json           src/locales/ru.json
index.html                    src/main.tsx
tsconfig.json                 .npmrc
.gitignore                    src/shims/use-sync-external-store-shim.ts
src/locales/catalogues.test.ts
.github/workflows/ci.yml      .github/dependabot.yml

Это набор SDK 1.15.0. Он меняется вместе с SDK — доверяйте выводу своего запуска, а не этому списку. Несколько файлов несут нагрузку, которая неочевидна:

  • src/shims/use-sync-external-store-shim.ts вместе с алиасом в vite.config.ts — это то, что не даёт вашему remote увезти вторую копию React и упасть внутри оболочки. Объяснение — в разделе Федерация и оболочка; не удаляйте ни одну из половин.
  • pnpm-workspace.yaml содержит разрешения на сборочные скрипты, нужные свежей установке. Без него pnpm 11 откажется устанавливать собственное дерево зависимостей каркаса. Из-за него же обычный pnpm add <пакет> останавливается с ERR_PNPM_ADDING_TO_ROOT: добавляйте зависимости через pnpm add -w <пакет> (-D -w — для зависимостей разработки).
  • AGENTS.md / CLAUDE.md несут правила платформы, которые ИИ-агент, работающий в вашем репозитории, иначе не смог бы увидеть.
  • .github/workflows/ci.yml на GitHub-hosted runners устанавливает зависимости, проверяет манифест, типы и тесты, собирает настоящий remote федерации и проверяет его экспорт ./Shell. Smoke-проверку интеграции с оболочкой он запустить не может: она лежит в закрытом репозитории. Эту проверку заменяет загрузка модуля в оболочку вашей песочницы (шаг 7).

Каркас содержит только фронтенд — без backend/, и в сгенерированном манифесте не объявлено ни одного mcpTools. Добавьте и то, и другое, когда понадобится; см. справочник по манифесту.

--category обязателен и должен быть одним из: core, operations, engagement, infrastructure, analytics, ai. Если фронтенд у вас уже есть и нужен только манифест — используйте init-manifest.

Хотите сначала посмотреть на настоящий модуль?

tv-module-example — каноническая эталонная реализация: федерация инструментов Copilot, проверка запросов по HMAC-подписи, отчёт о живости. Репозиторий закрытый, копия предоставляется по запросу: developers@tango.vision. В каталоге модулей перечислены модули, работающие на платформе сегодня.

2. Разберитесь с манифестом ​

Генератор пишет валидный стартовый манифест. Вот его форма с заполненными полями, которые вы реально будете править:

json
{
  "$schema": "./node_modules/@tv/extension-sdk/manifest.schema.json",
  "sdkVersion": "1.15.0",
  "id": "@acme/module-hello",
  "name": "Hello Module",
  "description": "Приветствует оператора и перечисляет помещения активного здания.",
  "version": "1.0.0",
  "minCoreVersion": ">=2.0.0",
  "category": "operations",
  "buildingTypes": ["all"],
  "capabilities": { "provides": [], "requires": [] },
  "permissions": [
    {
      "subject": "building.spaces",
      "actions": ["read"],
      "reason": "Выводит список помещений на главной странице модуля."
    }
  ],
  "events": { "publishes": [], "subscribes": [] },
  "mcpTools": [],
  "ui": {
    "remoteEntry": "./Shell",
    "routes": [{ "path": "/hello" }],
    "navigation": [
      { "label": "Hello", "icon": "Hand", "path": "/hello", "section": "operations" }
    ]
  },
  "lifecycle": {
    "healthEndpoint": "/health",
    "init": "on_demand",
    "dependencies": []
  },
  "author": { "name": "Acme Ltd" }
}

Несколько полей значат больше, чем кажется по их размеру:

  • id — ваша собственная npm-область, не @tv. Её задаёт --scope; без этого флага генератор ставит @tv/…, область самой Tango Vision.
  • permissions[].reason — показывается дословно администратору, который устанавливает ваш модуль. Пишите для него, а не для себя.
  • ui.remoteEntry — должно остаться ./Shell. Оболочка ищет ровно это имя; tv-sdk check-exposes это проверяет.
  • lifecycle.init — on_demand загружает модуль лениво, при обращении к его маршруту. on_boot используйте, только если оболочка действительно не может стартовать без вас.

Все поля описаны в справочнике по манифесту.

3. Проверьте его ​

bash
npx @tv/extension-sdk validate module-manifest.json

Для манифеста выше SDK 1.15.0 выводит:

✓ /…/tv-module-hello/module-manifest.json is a valid module manifest.
  id:      @acme/module-hello
  version: 1.0.0
  core:    >=2.0.0

⚠ 1 item(s) need a reviewer's sign-off before a cross-organization install:
  - permissions.0.subject: "building.spaces" is a kernel-domain subject (building.*) requested by a module outside @tv/ — allowed, but it needs a reviewer's sign-off before a cross-organization install. (reserved_subject_request)

Пометка ⚠ — это информация, а не ошибка: модуль вне @tv, который просит разрешение building.*, проходит проверку ревьюера, прежде чем его установят в другой организации. Команда завершается с кодом 0.

Если поле неверно, валидатор называет его и завершается с кодом 1:

✗ /…/tv-module-hello/module-manifest.json failed validation:
  - permissions.0.actions.0: Invalid option: expected one of "read"|"write"|"delete"|"subscribe"|"publish" (invalid_value)

Валидатор проверяет форму субъекта разрешения (строчные буквы, части через точку), но не то, существует ли такой субъект: building.nonsense пройдёт с той же пометкой ⚠. Сверяйте свои субъекты с каталогом разрешений сами.

Сгенерированный ci.yml уже выполняет эту проверку. Если вы добавляете CI в существующий репозиторий, вот шаги, которые генерирует init-module --external (SDK 1.15.0, комментарии сокращены; 1.15.1 генерирует те же шаги с node-version: '24'). Здесь не используется ни один action из репозиториев Tango Vision: они закрытые, и задание со ссылкой на такой action падает на старте.

yaml
# .github/workflows/ci.yml, задание `checks`, runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: '22'

      # Версия pnpm платформы — выписана явно: action, где она хранится, закрыт.
      - uses: pnpm/action-setup@v4
        with:
          version: 10.18.2

      # npmrc в пределах задания, в $RUNNER_TEMP, а не в ~/.npmrc; токен —
      # секрет репозитория TV_NPM_TOKEN, в логе он замаскирован.
      - name: Configure @tv registry auth
        env:
          TV_NPM_TOKEN: ${{ secrets.TV_NPM_TOKEN }}
        run: |
          if [ -z "$TV_NPM_TOKEN" ]; then
            echo "::error::TV_NPM_TOKEN is not set. Add it as a repository secret; @tv/* cannot be installed without it."
            exit 1
          fi
          echo "::add-mask::$TV_NPM_TOKEN"
          mkdir -p "$RUNNER_TEMP/tv-registry"
          {
            echo "registry=https://registry.npmjs.org/"
            echo "@tv:registry=https://npm.k8s.tangovision.dev/"
            echo "//npm.k8s.tangovision.dev/:_authToken=$TV_NPM_TOKEN"
          } > "$RUNNER_TEMP/tv-registry/.npmrc"
          echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/tv-registry/.npmrc" >> "$GITHUB_ENV"

      # Закоммитьте pnpm-lock.yaml: без него --frozen-lockfile падает.
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Validate the manifest
        run: pnpm exec tv-sdk validate module-manifest.json

      - name: Typecheck
        run: pnpm typecheck

      - name: Tests
        run: pnpm test

      - name: Build federation remote
        run: pnpm build

      - name: Check the canonical expose
        run: pnpm exec tv-sdk check-exposes module-manifest.json

      - name: Remove the registry credential
        if: always()
        run: rm -rf "$RUNNER_TEMP/tv-registry"

До первого запуска добавьте TV_NPM_TOKEN в Settings → Secrets and variables → Actions. Шага smoke-проверки интеграции с оболочкой здесь нет: этот action лежит в закрытом репозитории. Класс отказов, который он ловит (модуль собирается, проходит тесты и публикуется чисто, а затем ломается при монтировании внутри Building OS), вы наблюдаете иначе — загрузив свой remote в оболочку песочницы: см. Ваш модуль в оболочке песочницы и Федерация и оболочка.

Автодополнение в редакторе

Начиная с SDK 1.1.0 публикуемый manifest.schema.json пригоден для проверки в редакторе: укажите $schema на установленную копию, и редактор будет подсказывать поля и подсвечивать ошибки прямо при вводе:

json
{
  "$schema": "./node_modules/@tv/extension-sdk/manifest.schema.json",
  "sdkVersion": "1.15.0",
  ...
}

tv-sdk init-module добавляет эту строку за вас. В версиях 1.0.x схема генерировалась в режиме output и отклоняла манифесты, которые tv-sdk validate принимает, — если вы закреплены ниже 1.1.0, не указывайте $schema.

Авторитетной проверкой остаётся tv-sdk validate — именно её выполняют CI и реестр.

4. Соберите фронтенд ​

Ваш модуль экспортирует федеративный Shell. Внутри него следуйте за зданием, которое пользователь выбрал в шапке Building OS, и читайте помещения этого здания из API платформы. Замените src/Shell.tsx:

tsx
// src/Shell.tsx
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { authorizationHeader } from '@tv/extension-sdk/context';
import { useSelectedBuildingId } from '@tv/extension-sdk/react';
import './i18n';

// A list endpoint answers one page: { data, total, limit, offset }.
interface Page<T> {
  data: T[];
  total: number;
  limit: number;
  offset: number;
}

async function fetchSpaces(buildingId: string): Promise<Page<unknown>> {
  const res = await fetch(`/api/v1/buildings/${encodeURIComponent(buildingId)}/spaces`, {
    headers: authorizationHeader(),
  });
  if (!res.ok) throw new Error(`spaces: HTTP ${res.status}`);
  return (await res.json()) as Page<unknown>;
}

export default function Shell() {
  const { t } = useTranslation();
  const buildingId = useSelectedBuildingId();

  const spaces = useQuery({
    queryKey: ['spaces', buildingId],
    enabled: buildingId !== null,
    queryFn: () => fetchSpaces(buildingId!),
  });

  let status: string;
  if (buildingId === null) status = t('shell.selectBuilding');
  else if (spaces.isPending) status = t('shell.loading');
  else if (spaces.isError) status = t('shell.loadFailed');
  else status = t('shell.spaces', { count: spaces.data.total });

  return (
    <div style={{ padding: '1rem' }}>
      <h1>{t('shell.title')}</h1>
      <p>{status}</p>
    </div>
  );
}

authorizationHeader() несёт токен вошедшего пользователя; вызывайте его на каждый запрос и используйте относительный путь /api/…. Правила — в разделе Вызов API из модуля.

Тексты берутся из каталогов модуля, а не из JSX: этого требует AGENTS.md каркаса, а его тест каталогов падает, если ключи в двух файлах не совпадают. Замените оба каталога:

json
{
  "shell": {
    "title": "Hello",
    "selectBuilding": "Select a building to continue.",
    "loading": "Loading…",
    "loadFailed": "Could not load the spaces.",
    "spaces_one": "{{count}} space",
    "spaces_other": "{{count}} spaces"
  }
}
json
{
  "shell": {
    "title": "Hello",
    "selectBuilding": "Выберите здание, чтобы продолжить.",
    "loading": "Загрузка…",
    "loadFailed": "Не удалось загрузить помещения.",
    "spaces_one": "{{count}} помещение",
    "spaces_few": "{{count}} помещения",
    "spaces_many": "{{count}} помещений"
  }
}

Число выводится через множественные формы i18next: английскому нужны _one и _other, русскому — _one, _few и _many («1 помещение», «3 помещения», «5 помещений»). Тест каталогов сравнивает ключи без этих суффиксов.

Для useQuery нужен '@tanstack/react-query' в списке shared в vite.config.ts: клиент запросов предоставляет Building OS, а необщая копия его не увидит (почему — объясняет комментарий в сгенерированном конфиге). Замените строку shared на эту:

ts
shared: ['react', 'react-dom', 'react-router', 'react-router-dom', '@tanstack/react-query'],

Автономному превью нужен собственный клиент запросов. В SDK 1.15.0 в src/main.tsx его нет, поэтому pnpm dev показывает пустую страницу, а в консоли — No QueryClient set, use QueryClientProvider to set one. Замените src/main.tsx (1.15.1 и новее генерируют равнозначный файл):

tsx
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PlatformProvider } from '@tv/extension-sdk/react';
import { createMockPlatformContext } from '@tv/extension-sdk/testing';
import Shell from './Shell';

const container = document.getElementById('root');
if (!container) throw new Error('#root is missing from index.html');

// Standalone `pnpm dev` only. Building OS provides the real query client,
// router and platform context, so never mount any of them inside Shell.
const queryClient = new QueryClient();
const platform = createMockPlatformContext();

createRoot(container).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <PlatformProvider value={platform}>
          <Shell />
        </PlatformProvider>
      </BrowserRouter>
    </QueryClientProvider>
  </StrictMode>,
);

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

Запустите pnpm dev и откройте http://localhost:5001. Вы увидите заголовок и «Выберите здание, чтобы продолжить.»: вне оболочки выбор здания никто не публикует и вошедшего пользователя нет, поэтому число помещений появится только в тестах (шаг 6) и в оболочке песочницы (шаг 7). Превью открывается на русском — это язык платформы по умолчанию. Чтобы переключить его на английский, выполните в консоли браузера localStorage.setItem('tv-platform-locale', 'en') и перезагрузите страницу.

null означает, что здание не выбрано: оболочка ещё грузится или вы работаете автономно. Нарисуйте для этого случая явное пустое состояние. Никогда не подставляйте вместо него другое здание: названия зданий повторяются между организациями, и так вы молча покажете данные одной организации под именем другой.

В Building OS ровно один селектор здания — тот, что в шапке. Читайте его и не добавляйте собственный второй: два селектора начинают наглядно противоречить друг другу, стоит тронуть любой.

usePlatformContext() не работает внутри федеративного модуля

Именно эта ошибка стоит новым модулям больше всего времени. PlatformContext передаётся через React Context, а React Context не пересекает границу Module Federation — это работало бы, только если бы @tv/extension-sdk был общим синглтоном и в хосте, и в каждом remote, а сегодня это не так. Поэтому внутри вашего Shell usePlatformContext() и useBuilding() бросают исключение, даже несмотря на то, что оболочка действительно отрисовала вокруг вас <PlatformProvider>.

useSelectedBuildingId() и useBuildingSelection() используют глобальные каналы и работают в обоих местах. usePlatformContext() применяйте только в коде, выполняющемся внутри бандла оболочки, — и в собственных тестах, где провайдер вы отрисовываете сами.

5. (Опционально) Объявите возможность бэкенда ​

Если у вашего модуля есть бэкенд на NestJS:

ts
import { Controller, Get } from '@nestjs/common';
import { ModuleCapability, RequiresLicense } from '@tv/extension-sdk/nestjs';

@ModuleCapability({ id: 'hello.greeting', version: '1.0.0' })
@Controller('api/v1/buildings/:buildingId/hello')
export class HelloController {
  @RequiresLicense('@acme/module-hello')
  @Get()
  greet() {
    return { message: 'hello' };
  }
}

@RequiresLicense() пока ничего не проверяет

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

Про остальное — проверку токена вызывающего, CORS для оболочки вашей песочницы и вызов tv-api с этим же токеном — см. Добавьте бэкенд.

6. Протестируйте его ​

Тесты каркаса выполняются в Node без DOM, а библиотеки для тестирования компонентов в каркасе нет. Добавьте и то, и другое (-w в каркасе обязателен, см. шаг 1):

bash
pnpm add -D -w @testing-library/react @testing-library/dom @testing-library/jest-dom jsdom

Затем добавьте src/Shell.test.tsx:

tsx
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BUILDING_SELECTION_STORAGE_KEY } from '@tv/extension-sdk/context';
import i18n from './i18n';
import Shell from './Shell';

// What the Building OS header publishes when the user picks a building.
// useSelectedBuildingId() reads this; it does not read a mock PlatformContext.
function selectBuilding(id: string, name: string) {
  localStorage.setItem(
    BUILDING_SELECTION_STORAGE_KEY,
    JSON.stringify({ state: { selectedBuildingId: id, buildings: [{ id, name }] } }),
  );
}

// Building OS provides the query client; a test provides its own.
function renderShell() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  render(
    <QueryClientProvider client={queryClient}>
      <Shell />
    </QueryClientProvider>,
  );
}

beforeAll(async () => {
  await i18n.changeLanguage('ru');
});

beforeEach(() => {
  localStorage.clear();
});

afterEach(() => {
  cleanup();
  vi.unstubAllGlobals();
});

test('asks for a building when none is selected', () => {
  renderShell();
  expect(screen.getByText('Выберите здание, чтобы продолжить.')).toBeInTheDocument();
});

test('shows how many spaces the selected building has', async () => {
  selectBuilding('b1', 'Demo Mall');
  const fetchMock = vi.fn(async () =>
    Response.json({ data: [], total: 3, limit: 50, offset: 0 }),
  );
  vi.stubGlobal('fetch', fetchMock);

  renderShell();

  expect(await screen.findByText('3 помещения')).toBeInTheDocument();
  expect(fetchMock).toHaveBeenCalledWith('/api/v1/buildings/b1/spaces', expect.anything());
});
bash
pnpm test

Четыре вещи делают этот тест похожим на то, что происходит в оболочке:

  • // @vitest-environment jsdom в первой строке даёт этому файлу DOM.
  • Выбор здания записан так же, как его публикует шапка Building OS. useSelectedBuildingId() читает именно его, а не PlatformProvider.
  • fetch подменён. В тесте относительный путь /api/... никто не обслуживает: без подмены fetch из Node выбрасывает TypeError: Failed to parse URL from /api/v1/buildings/b1/spaces, и Shell показывает ошибку загрузки. (Вне оболочки authorizationHeader() возвращает {}, так что токена в запросе тоже не было бы.)
  • Тест сам оборачивает компонент в QueryClientProvider, как это делает Building OS. Повторы запросов выключены, чтобы ошибка проявлялась сразу.

Тест явно включает русский язык через i18n.changeLanguage('ru'), чтобы не зависеть от того, что записано в хранилище. Другие приёмы — смена здания и неудачный запрос — описаны в разделе Тестирование.

7. Запустите его в песочнице ​

Прежде чем модуль увидит кто-то ещё, запустите его в оболочке своей песочницы на настоящем (изолированном) здании. Если у вашей команды уже есть общая песочница, работайте в ней, а не создавайте вторую: Два разработчика, одна песочница. Иначе создайте её:

bash
npx @tv/extension-sdk sandbox create \
  --name=hello-dev --type=office --storeys=2 --area-sqm=5000 --ttl-days=30

Дальше отдавайте remote со своей машины, установите модуль в песочницу и откройте его: шаги 3–6 руководства От нуля до вашего модуля в вашей песочнице. Полный жизненный цикл описан в разделе Песочницы по запросу.

Что вы только что узнали ​

  • tv-sdk init-module --external --scope --author даёт заведомо рабочую форму; не собирайте её вручную.
  • Манифест — это ваш контракт; validate и check-exposes должны быть в CI.
  • До состояния платформы модуль добирается через вызовы SDK: здание — через useSelectedBuildingId(), authorizationHeader() — в каждом запросе к относительному пути /api/…. usePlatformContext() — для тестов.
  • Тесты записывают выбор здания, подменяют fetch и дают компоненту клиент запросов — так же, как Building OS.

Далее: разберитесь с PlatformContext, где описан и вызов API из модуля, и с тестированием.

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