Middleware
Middleware — поверхность интроспекции рантайма. Это обычный объект с именем и максимум семью хуками жизненного цикла; рантайм вызывает только те, которые ты реализовал, — по мере того как рассылаются события, отрабатывают обработчики, срабатывают действия и возникают ошибки. Middleware также может отменить срабатывание ещё до того, как его увидит хоть один обработчик.
Именно на middleware подписан инспектор, на нём построен @triggery/devtools-redux, и через него же добавляется структурированное логирование или метрики производительности вокруг твоих триггеров — без правок в файлах самих триггеров.
Тип Middleware
Заголовок раздела «Тип Middleware»Семь хуков. Каждый необязателен. name — единственное обязательное поле; имя появляется в DevTools и помогает понять, какой именно middleware ведёт себя некорректно. Рантайм хранит middleware в том порядке, в каком их передали в createRuntime, и обходит ими каждый хук в этом же порядке.
Жизненный цикл по шагам
Заголовок раздела «Жизненный цикл по шагам»При типичном срабатывании, которое попадает в один триггер и запускает одно действие, рантайм вызывает хуки в такой последовательности:
Сам обработчик для middleware непрозрачен — никаких onHandlerStart / onHandlerEnd нет. Это сделано намеренно: обработчик — это замыкание, которое написал ты, а не артефакт рантайма. Рантайм инструментирует границы (fire, match, skip, action, cascade), а middleware, которому нужен тайминг обработчика, собирает его сам — замеряя интервал между onBeforeMatch и последним onActionEnd для заданного runId.
Каждый хук в деталях
Заголовок раздела «Каждый хук в деталях»onFire(ctx: FireContext)
Заголовок раздела «onFire(ctx: FireContext)»Срабатывает один раз на срабатывание — верхнеуровневое или каскадное — до того, как найден хотя бы один триггер. Это единственный хук, который может отменить срабатывание:
ctx несёт имя события, payload, cascadeDepth и (для каскадов) parentTriggerId / parentRunId. Поле parentContext для middleware непрозрачно — обращайся с ним как с чёрным ящиком.
Отмена здесь останавливает всё срабатывание целиком — ни один триггер даже не рассматривается. Если нужно отменить срабатывание только для одного триггера, пропусти onFire и собери нужные данные в onBeforeMatch; пер-триггерная отмена не поддерживается намеренно — так порядок диспатча остаётся читаемым.
onBeforeMatch(ctx: MatchContext)
Заголовок раздела «onBeforeMatch(ctx: MatchContext)»Срабатывает один раз на пару (event, trigger), сразу после того, как диспетчер достал триггер из индекса событий — до ограничений concurrency, проверки required и обработчика.
Используй, чтобы залогировать, какие триггеры рассматривались для этого события, или зафиксировать таймстамп намерения. Из этого хука прервать нельзя; если нужно подавить срабатывание — делай это из onFire.
onSkip(ctx: SkipContext)
Заголовок раздела «onSkip(ctx: SkipContext)»Срабатывает, когда найденный триггер не запускается. Сегодня сюда попадают три причины:
concurrency-take-firstилиconcurrency-exhaust— предыдущий запуск ещё выполняется.missing-required-condition:<name>— обязательное условие без зарегистрированного геттера.- Любая причина, которую custom middleware пишет в инспектор через публичное API.
onSkip — наблюдательный. Триггер уже пропущен к моменту вызова хука; middleware не может его вернуть.
onActionStart(ctx: ActionContext) / onActionEnd(ctx & { durationMs, result? })
Заголовок раздела «onActionStart(ctx: ActionContext) / onActionEnd(ctx & { durationMs, result? })»Срабатывают вокруг каждого вызова действия — включая вызовы через actions.debounce(...) / throttle(...) / defer(...). Два хука обрамляют пользовательский обработчик действия. durationMs измеряет время от вызова до резолва возвращённого промиса (для async-действий) или до синхронного возврата (для sync).
Поле result в onActionEnd — это значение, которое вернул обработчик действия (или то, к чему зарезолвился await-промис). Большинство обработчиков возвращают void; поле существует ради действий, у которых result имеет смысл (например, шаг редактирования, возвращающий преобразованный payload).
onError(ctx: ActionContext & { error: unknown })
Заголовок раздела «onError(ctx: ActionContext & { error: unknown })»Срабатывает, когда пользовательское действие бросает исключение или возвращает отклонённый промис. Ошибка никогда не вылетает из рантайма — диспетчер её ловит, вызывает onError каждого middleware и идёт дальше к следующему действию. Именно это не даёт одному сломанному реактору положить остальные действия триггера.
Если бросает сам обработчик (вне вызова действия), ошибка логируется рантаймом через console.error как последний рубеж обороны, и запуск завершается со статусом 'errored' — но onError зарезервирован за ошибками уровня действия, а не уровня обработчика. Снимок инспектора несёт ошибку обработчика в поле reason.
onCascade(ctx: CascadeContext)
Заголовок раздела «onCascade(ctx: CascadeContext)»Срабатывает, когда каскад отброшен — либо потому что cascadeDepth > maxCascadeDepth (kind: 'overflow'), либо потому что обнаружен цикл (kind: 'cycle'). Это observability-хук для предохранителей, описанных в разделе Каскады.
Хук вызывается только при переполнении или цикле. Легитимные каскады — в пределах лимита глубины и без циклов — onCascade не вызывают. Чтобы наблюдать каждый каскад, используй onFire и проверяй cascadeDepth > 0.
Подключение middleware
Заголовок раздела «Подключение middleware»Middleware задаётся при создании рантайма и неизменяем на всё время его жизни. В V1 нет runtime.use(...) — такая гибкость стоит дополнительной ветки на горячем пути и оставлена за бортом намеренно. Если нужен условный middleware — ветвись на создании рантайма:
Для тестов, которым нужно менять стек, создавай свежий рантайм на каждый тест через createTestRuntime(options):
См. Модульные тесты.
Порядок исполнения
Заголовок раздела «Порядок исполнения»Для каждого хука middleware выполняются в том порядке, в котором были переданы в createRuntime — индекс 0 первым, N-1 последним. Порядок важен в двух ситуациях:
-
Отмена в
onFire. Побеждает первый middleware, вернувший{ cancel: true }; последующие в списке не увидят это срабатывание. Если у тебя есть middleware feature-флага и логгер, поставь логгер первым, чтобы он зафиксировал попытку срабатывания до отмены. -
Отчётность об ошибках вокруг общего состояния. Если два middleware изменяют общую мапу по ключу
runId, пиши продьюсера раньше потребителя. Рантайм не даёт гарантий атомарности между вызовами middleware (это синхронный for-of); два middleware, наблюдающие один и тот жеonActionEnd, видят его строго по порядку.
onActionEnd и onError запускаются после того, как зарезолвится промис действия — поэтому два middleware, наблюдающие конец async-действия, видят друг друга в той же микротаске, но после любого кода, выполненного в await-задержанных фреймах между onActionStart и резолвом.
Ошибки из middleware изолированы
Заголовок раздела «Ошибки из middleware изолированы»Хуки middleware выполняются внутри try/catch-границы, унаследованной от вызывающего кода. Middleware, бросивший внутри onFire, рантайм трактует как вернувший undefined (без отмены) — исключение попадает на границе console.error диспетчера как последняя инстанция. Остальные middleware продолжают работать, и триггер диспатчится нормально.
Это правильный дефолт для слоя интроспекции. Сломанный логгер не должен класть пайплайн уведомлений. Это также означает: если твой middleware глотает свои ошибки, ты можешь не заметить баг — пиши на middleware юнит-тест и не полагайся, что рантайм обнаружит его ошибки за тебя.
Распространённые middleware
Заголовок раздела «Распространённые middleware»У большинства команд набор примерно одинаковый. Вот формы; реализации достаточно коротки, чтобы жить в твоём коде.
Console logger
Заголовок раздела «Console logger»Замер производительности
Заголовок раздела «Замер производительности»Редактирование payload (на стороне продьюсера, а не в middleware)
Заголовок раздела «Редактирование payload (на стороне продьюсера, а не в middleware)»Не пытайся редактировать внутри onFire. Правильная форма — тонкая обёртка вокруг runtime.fire:
Так представление рантайма о мире совпадает с представлением диспетчера, а граница middleware остаётся наблюдательной.
Сохранение запусков
Заголовок раздела «Сохранение запусков»Для реальной реализации подпишись на буфер инспектора через runtime.subscribe(listener) — получишь полный снимок один раз на запуск, а не по одному на каждое действие. См. Инспектор.
Готовые middleware
Заголовок раздела «Готовые middleware»Писать самому необязательно. Пакеты first-party экспортируют по одному или несколько middleware:
@triggery/devtools-redux— прогоняет каждое срабатывание / пропуск / действие через расширение Redux DevTools. Положи вmiddleware: [...], открой панель Redux — больше ничего подключать не надо.@triggery/devtools-bridge— postMessage-мост к standalone-панели Triggery.@triggery/socket— мост срабатываний триггеров черезBroadcastChannel/ WebSocket; полезен для мультитабовой оркестрации.
Они компонуются. Типичный dev-стек: cascadeLogger, perfTiming, @triggery/devtools-redux. Типичный prod-стек: пустой или только sentryReporter.
Чек-лист ревьюера
Заголовок раздела «Чек-лист ревьюера»- У каждого middleware уникальный
name. (Рантайм не проверяет, но в devtools и логах два'logger'быстро запутывают.) - Хуки синхронные и быстрые. Async-работа внутри хука не задерживает диспатч, но может породить unhandled rejection — оборачивай внутри.
- Ни один middleware не изменяет
ctx. Если нужна производная форма — собери её локально. -
{ cancel: true }вonFireиспользуется только когда можешь чётко сформулировать, почему это срабатывание должно быть невидимо каждому триггеру. Для пер-триггерных ограничений объявиrequired-условие. - Порядок middleware в
createRuntimeсоответствует желаемому порядку лога и отмен.