События
Модули общаются через версионированную шину событий, а не вызывают друг друга напрямую. Ваш модуль объявляет в манифесте, что он публикует и на что подписывается; платформа проверяет эти объявления до того, как вы выпустите релиз.
Объявление в манифесте
"events": {
"publishes": [
{ "name": "cafm.work-order.created", "version": "1.0.0" }
],
"subscribes": [
{ "name": "building.alarm.triggered", "version": "1.0.0" }
]
}Скажите, что читаете и что обещаете, — payloadContract
Любая из двух сторон объявления события может нести payloadContract. Именно он позволяет check-pact сказать что-то содержательное, и именно его чаще всего забывают указать.
"events": {
"publishes": [
{
"name": "cafm.work-order.created",
"version": "1.0.0",
"payloadContract": {
"type": "object",
"required": ["workOrderId", "buildingId"],
"properties": {
"workOrderId": { "type": "string" },
"buildingId": { "type": "string" },
"priority": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "URGENT"] }
}
}
}
],
"subscribes": [
{
"name": "building.alarm.triggered",
"version": "1.0.0",
"payloadContract": {
"type": "object",
"required": ["buildingId", "severity"],
"properties": {
"buildingId": { "type": "string", "reason": "Scopes the work order we open." },
"severity": { "type": "string", "reason": "Maps onto work-order priority." }
}
}
}
]
}Стороны означают намеренно разное:
- В
subscribesэто то подмножество полезной нагрузки издателя, которое вы действительно читаете.check-pactзавалит проверку, если издатель не даёт объявленное вами поле, — ради этого всё и затевалось.reason— свободный текст о том, зачем вам поле; пишите его для сопровождающего со стороны издателя, который увидит эту строку, когда соберётся менять нагрузку. - В
publishesэто то, что вы гарантируете отправлять. По нему проверяют себя потребители.
Запись в publishes без payloadContract всё равно регистрирует событие: вы его публикуете, значит подписчик никогда не должен получить EVENT_NOT_PUBLISHED. Вместо этого запись помечается как непрозрачная, и check-pact сообщает о ней информационным PRODUCER_PAYLOAD_OPAQUE — пробел в покрытии виден, но ничей CI при этом не краснеет.
Откуда берутся события
У событий два источника, и от того, кто издаёт событие, которое вы читаете, зависит выбор инструмента ниже:
- События платформы (
building.*) публикует сама платформа. Они версионируются вместе с SDK в его снимке каталога (tv-events.snapshot.json) — полный список со схемами полезной нагрузки: События платформы. - События модулей (
cafm.*,service-desk.*, …) публикуют отдельные модули. Центрального каталога для них нет — так задумано: события, публикуемые модулем, объявляются в манифесте этого модуля (events.publishes) и больше нигде, поэтому не существует реестра, куда нужно добиваться добавления своего события. Кто что публикует и читает — в каталоге модулей.
Проверка ваших подписок — check-pact
check-pact проверяет, что каждый контракт в events.subscribes действительно совместим со схемой издателя:
# Если вы подписаны только на первичные события платформы:
npx @tv/extension-sdk check-pact module-manifest.json
# Если вы подписаны на событие, публикуемое другим МОДУЛЕМ, — подмешайте
# его манифест, потому что событий модулей нет в первичном каталоге:
npx @tv/extension-sdk check-pact module-manifest.json \
--producer=../tv-module-cafm/module-manifest.jsonПередавайте по одному --producer на каждый издающий модуль. Без этого подписка на событие модуля падает как «неизвестное событие» — это корректная работа инструмента, а не ошибка.
Защита ваших собственных событий — check-events
Если вы публикуете события, зафиксируйте снимок их схем и закоммитьте его. check-events сравнивает текущие схемы со снимком и валит CI на любом ломающем изменении — до того, как оно дойдёт до ваших потребителей:
npx @tv/extension-sdk snapshot-events ./tv-events.snapshot.json # перегенерировать
npx @tv/extension-sdk check-events ./tv-events.snapshot.json # проверить в CIОбе команды принимают путь к снимку аргументом.
События платформы в интерфейсе модуля
Фронтенд модуля получает события платформы через usePlatformEvent из @tv/extension-sdk 1.18.0 или новее. Он имеет уровень @experimental, как и subscribePlatformEvent и createMockPlatformEvents ниже: полезны, но могут измениться в любом релизе (Уровни стабильности).
import { usePlatformEvent } from '@tv/extension-sdk/react';
import { useState } from 'react';
export function AlarmBanner() {
const [alarm, setAlarm] = useState<string | null>(null);
// Тревоги по зданию, выбранному в шапке оболочки, по мере поступления.
usePlatformEvent('building.alarm.triggered', (a) => {
setAlarm(`${a.severity}: ${a.title}`);
});
return alarm ? <p role="status">{alarm}</p> : null;
}Что приходит и почему:
- Семь событий — те, что tv-api отправляет в браузер:
building.point.updated,building.alarm.triggered,building.fault.createdи четыре событияwifi-sensing.*. Их полезная нагрузка типизирована; для событийbuilding.*см. События платформы. Внутри оболочки и с тестовой заглушкой (ниже) подписка на что-то другое выбрасываетTypeError. События, которые публикуют другие модули, в браузер не попадают вовсе. - Только по зданию, выбранному в шапке, и только то, что вошедшему пользователю можно читать. Тревогам и неисправностям (
building.alarm.triggered,building.fault.created) нужноbuilding.faults:read, показаниям (building.point.updated) —telemetry.points:subscribe, ровно как в REST API. Событиямwifi-sensing.*разрешение не нужно: достаточно войти в организацию, которой принадлежит здание, — как и для чтения данных WiFi-сенсинга через REST. - Собственная копия каждой полезной нагрузки. Другой модуль не может её изменить.
- Публиковать нечего. Фронтенд модуля не публикует события: меняйте состояние через API, а объявлять о случившемся будет бэкенд, которому принадлежат данные (ниже).
Вне оболочки (автономная страница под pnpm dev, модульный тест без заглушки) и в оболочке, где этого канала ещё нет, ничего не приходит и ничего не выбрасывается: подписка ничего не делает, какой бы тип вы ни передали, поэтому тот же код там работает и ничего не делает. isPlatformEventsAvailable() из @tv/extension-sdk/context (тоже @experimental) показывает, какой это случай. В тестах поставьте заглушку и отправляйте события сами:
import { createMockPlatformEvents } from '@tv/extension-sdk/testing';
const events = createMockPlatformEvents();
const uninstall = events.install(); // нужна тестовая среда с DOM
// отрендерите компонент, затем:
events.emit('building.alarm.triggered', {
buildingId, faultId, severity: 'high', title: 'Приточный воздух слишком тёплый',
});
uninstall();Вне React то же делает subscribePlatformEvent(type, handler) из @tv/extension-sdk/context; он возвращает функцию отписки (пустую, если оболочка событий не доставляет).
Не usePlatformContext().eventBus
usePlatformContext() внутри оболочки выбрасывает ошибку: React-контекст не пересекает границу федерации (Федерация и оболочка). Используйте usePlatformEvent.
Публикация и получение в бэкенде
События модулей (cafm.work-order.created и подобные) ходят между бэкендами модулей через NATS JetStream, а не через браузер. Субъект — tv.building.<buildingId>.<событие>, сообщение — конверт вокруг вашей полезной нагрузки; оба строит SDK:
import { buildSubject, createEnvelope } from '@tv/extension-sdk/events';
const subject = buildSubject(wo.buildingId, 'cafm.work-order.created');
const envelope = createEnvelope({
type: 'cafm.work-order.created',
buildingId: wo.buildingId,
producer: 'cafm',
payload: { workOrderId: wo.id, buildingId: wo.buildingId, priority: wo.priority },
});
// опубликуйте `envelope` в `subject` своим клиентом NATSБэкенд подписывается так же, на tv.building.*.<событие> по всем зданиям. События платформы (building.*) приходят туда же.
Пока не в песочнице
В песочницах нет сервера NATS, а ваш бэкенд работает на вашей машине, поэтому публиковать и получать события модулей в песочнице сейчас нельзя. Разрабатывайте обработчик на полезных нагрузках, которые строите в тесте, и объявите события в манифесте: check-pact проверит вашу сторону каждого контракта.
Нужен ответ? Это не событие
Событие — это объявление. На него никто не отвечает, и публикация ничего не говорит о том, обработал ли его кто-нибудь и когда.
Когда коду нужен результат, вызовите API модуля, которому принадлежат данные: createPlatformApiClient() для ресурсов tv-api (здания, помещения, неисправности — см. клиент api), ваш собственный бэкенд (Добавьте бэкенд) — для собственных ресурсов модуля. Событие — это то, как о случившемся узнают все остальные:
import { createPlatformApiClient } from '@tv/extension-sdk/context';
const api = createPlatformApiClient();
// Вызов API и ЕСТЬ запрос-ответ — созданная сущность приходит в ответе.
// `createPlatformApiClient()` доходит только до tv-api; заявки — собственный
// ресурс модуля CAFM, поэтому вызов идёт в его собственный бэкенд (см.
// «Добавьте бэкенд»), а не через этот клиент.
const wo: WorkOrder = await createWorkOrder(dto);
// Бэкенд CAFM, которому принадлежат заявки, затем публикует
// cafm.work-order.created для других модулей.Правило: API — для вопросов, события — для объявлений. Если вы ловите себя на том, что публикуете событие и ждёте «ответное», замените эту пару одним вызовом API.
Версионирование
Имена событий имеют пространство имён (<module>.<entity>.<action>) и несут semver-version. Несовместимое изменение формы полезной нагрузки означает новую мажорную версию этого события — потребители фиксируют ту версию, которую понимают, поэтому вы можете развивать события, не ломая их.
→ Далее: Тестирование