Ваш первый модуль
Мы соберём минимальный модуль, который монтирует страницу в оболочке Building OS и читает данные выбранного здания из API платформы. Бюджет: 30 минут.
Код на этой странице проверен на свежем каркасе --external из SDK 1.15.0: если вставлять его по порядку, pnpm typecheck, pnpm test и pnpm build проходят, так что копируйте его как есть.
1. Создание каркаса
SDK создаёт весь модуль за вас — манифест, конфиг Vite с уже подключённым экспортом федерации, исходники, каталоги локалей и рабочий процесс CI:
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. Разберитесь с манифестом
Генератор пишет валидный стартовый манифест. Вот его форма с заполненными полями, которые вы реально будете править:
{
"$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. Проверьте его
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 падает на старте.
# .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 на установленную копию, и редактор будет подсказывать поля и подсвечивать ошибки прямо при вводе:
{
"$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:
// 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 каркаса, а его тест каталогов падает, если ключи в двух файлах не совпадают. Замените оба каталога:
{
"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"
}
}{
"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 на эту:
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 и новее генерируют равнозначный файл):
// 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:
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):
pnpm add -D -w @testing-library/react @testing-library/dom @testing-library/jest-dom jsdomЗатем добавьте src/Shell.test.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());
});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. Запустите его в песочнице
Прежде чем модуль увидит кто-то ещё, запустите его в оболочке своей песочницы на настоящем (изолированном) здании. Если у вашей команды уже есть общая песочница, работайте в ней, а не создавайте вторую: Два разработчика, одна песочница. Иначе создайте её:
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 из модуля, и с тестированием.