Перейти к содержимому
GitHubXDiscord

Асинхронные обработчики

Обработчик триггера может быть sync или async. Асинхронные обработчики существуют ради одной причины: сценарий должен подождать что-то — fetch, чтение из IndexedDB, парсинг файла кусками, сообщение из воркера. Всё остальное (set state, диспатч действия, выпусти follow-up события) должно оставаться синхронным.

Async также меняет жизненный цикл. Sync-обработчик доходит до конца за один тик; async-обработчик имеет длительность, может быть superseded более новым прогоном и владеет AbortSignal всё окно между первым await и return.

Когда сценарий не может решить, что делать, не дождавшись результата I/O.

src/triggers/search.trigger.ts
import { createTrigger } from '@triggery/core';

type Hit = { id: string; title: string };

export const searchTrigger = createTrigger<{
  events:     { 'query-changed': { q: string } };
  conditions: { apiBase: string };
  actions:    { showResults: readonly Hit[]; showError: { message: string } };
}>({
  id: 'search-query',
  events: ['query-changed'],
  required: ['apiBase'],
  async handler({ event, conditions, actions, signal }) {
    if (!conditions.apiBase) return;
    const url = `${conditions.apiBase}/search?q=${encodeURIComponent(event.payload.q)}`;
    const res = await fetch(url, { signal });
    const hits = (await res.json()) as readonly Hit[];
    actions.showResults?.(hits);
  },
});

Обработчик async, fetch получает { signal }, действие запускается только после возврата сетевого ответа. Это и есть весь паттерн — 13 строк, никаких лишних примитивов.

Не делай обработчик async, если он ничего не await’ит. Три причины:

  1. Весь прогон становится промисом, который рантайм считает в полёте, что взаимодействует с concurrency. Sync-сценарий внезапно начинает вести себя как async.
  2. Ошибки, кинутые внутри async, ловятся как rejection’ы, а не синхронные throws. Поведение идентичное (см. Поток ошибок), но ты теряешь более простые стек-трейсы.
  3. Обработчик кажется “в полёте” один microtask, что может замаскировать баги, где ты ждал, что concurrency: 'take-first' пропустит следующий прогон.

Правило большого пальца: если нет await, убирай ключевое слово async.

ctx.signal — это AbortSignal. Рантайм им владеет, флипает его по трём причинам (разбираются в Отмена) и никогда не глотает ошибки твоего обработчика при этом.

Передавай signal в каждый async API, который его принимает:

async handler({ event, signal, actions }) {
  const res = await fetch(event.payload.url, { signal });
  const blob = await res.blob();
  // Некоторые API принимают signal напрямую:
  const bitmap = await createImageBitmap(blob);
  actions.show?.({ bitmap });
}

fetch, Request, Response.body.getReader().read(), setTimeout (через AbortSignal.timeout), Worker.postMessage с AbortSignal-обёртками, большинство клиентов баз и почти все современные stream API принимают AbortSignal. Используй его везде.

Между границами await рантайм не может прервать твой код — JavaScript однопоточный. Проверка — твоя ответственность. Самый дешёвый, идиоматичный способ — signal.throwIfAborted() сразу после каждого await:

async handler({ event, conditions, actions, signal }) {
  const profile = await fetchProfile(event.payload.userId, { signal });
  signal.throwIfAborted();

  const friends = await fetchFriends(profile.id, { signal });
  signal.throwIfAborted();

  const enriched = friends.map((f) => ({ ...f, viewerId: profile.id }));
  actions.showFriends?.(enriched);
}

Throw ловится рантаймом, распознаётся как abort (потому что signal.aborted == true), и прогон помечается aborted в инспекторе — не errored. Никакого console.error, никакого вызова onError в middleware. См. Поток ошибок.

Почему throw, а не if (signal.aborted) return? Throw короткозамыкает всю работу после него без необходимости тащить early-return по каждому хелперу, который ты зовёшь. С несколькими await’ами лесенка if/return становится шумной. throwIfAborted — одна строка.

Связь между возвращаемым значением обработчика и жизненным циклом прогона

Заголовок раздела «Связь между возвращаемым значением обработчика и жизненным циклом прогона»

Прогон считается завершённым когда возвращённый промис обработчика устаканивается. Никакого другого сигнала “конец прогона” нет. Три следствия:

  • Не делай fire-and-forget. Если ты зовёшь fetch(...) и не await’ишь — прогон резолвится до завершения запроса; рантайм не прервёт этот орфанный запрос, когда придёт следующее событие. Ты увидишь “зомби” сетевые вызовы в Network-вкладке.
  • Не сохраняй промис снаружи замыкания обработчика. Что переживёт обработчик — переживёт и signal.
  • Возвращаемое значение игнорируется. Обработчик возвращает void | Promise<void>. Возврат чего-то ещё — TS-ошибка.

Конкретно, вот так — сломано:

async handler({ event, signal, actions }) {
  // ✗ — орфанный запрос. `signal` не пробрасывается, и прогон закончится сейчас.
  fetch(`/log?event=${event.name}`);

  const res = await fetch(event.payload.url, { signal });
  actions.show?.(await res.json());
}

Фикс — одна строка:

async handler({ event, signal, actions }) {
  // ✓ — запрос принадлежит прогону; прерывается вместе с ним.
  await fetch(`/log?event=${event.name}`, { signal }).catch(() => {});

  const res = await fetch(event.payload.url, { signal });
  actions.show?.(await res.json());
}

Или, если лог реально best-effort и блокироваться на нём не хочется: запусти его из отдельного триггера с concurrency: 'take-every', тогда прогон завершится мгновенно, а лог пройдёт свой жизненный цикл.

Рантайм различает три терминальных статуса прогона, записывает их в инспектор и роутит в middleware:

СтатусКогдаХук middleware
'fired'Обработчик вернулся / зарезолвился нормально.onActionEnd для каждого действия
'aborted'Обработчик реджектнулся и signal.aborted === true.(нет — молча)
'errored'Обработчик реджектнулся и signal.aborted === false.onError

Разница между 'aborted' и 'errored' — единственное, что рантайм решает за тебя. Всё остальное — что логать, ретраить ли, как сообщать пользователю — живёт в middleware и хендлерах действий.

кастомный error-middleware
import type { Middleware } from '@triggery/core';

export const errorReporter: Middleware = {
  name: 'error-reporter',
  onError({ triggerId, runId, error, actionName }) {
    // До этого хука доходят только настоящие ошибки — aborts молча сбрасываются.
    sentry.captureException(error, {
      tags: { triggerId, runId, actionName },
    });
  },
};

Последовательные await’ы с переплетёнными проверками

Заголовок раздела «Последовательные await’ы с переплетёнными проверками»

Самый чистый async-обработчик читается сверху вниз: за каждым await сразу проверка abort. Никаких вложенных цепочек промисов, никаких .then.

src/triggers/onboarding.trigger.ts
import { createTrigger } from '@triggery/core';

type CurrentUser = { id: string };

export const onboardingTrigger = createTrigger<{
  events:     { 'user-signed-in': { userId: string } };
  conditions: { apiBase: string; currentUser: CurrentUser };
  actions:    {
    setProfile: { name: string; email: string };
    setOrgs:    readonly { id: string; name: string }[];
    setBilling: { plan: string; trialEndsAt: number };
    markReady:  void;
  };
}>({
  id: 'onboarding-load',
  events: ['user-signed-in'],
  required: ['apiBase', 'currentUser'],
  concurrency: 'take-latest',
  async handler({ event, conditions, actions, signal }) {
    if (!conditions.apiBase || !conditions.currentUser) return;
    const base = conditions.apiBase;

    const profile = await fetch(`${base}/users/${event.payload.userId}`, { signal }).then((r) =>
      r.json(),
    );
    signal.throwIfAborted();
    actions.setProfile?.(profile);

    const orgs = await fetch(`${base}/users/${event.payload.userId}/orgs`, { signal }).then((r) =>
      r.json(),
    );
    signal.throwIfAborted();
    actions.setOrgs?.(orgs);

    const billing = await fetch(`${base}/orgs/${orgs[0]?.id}/billing`, { signal }).then((r) =>
      r.json(),
    );
    signal.throwIfAborted();
    actions.setBilling?.(billing);

    actions.markReady?.();
  },
});

Если пользователь разлогинился посреди загрузки, следующее событие прервёт этот прогон между двумя await’ами. Частичное состояние (profile, может быть orgs) уже окажется в твоём сторе через хендлеры действий — и это правильно. Триггер документировал, что именно коммитит каждый await.

Если хочешь, чтобы вся последовательность была атомарной (“либо всё, либо ничего”) — собирай в локальные переменные и диспатчи действия только после последнего await:

async handler({ event, conditions, actions, signal }) {
  const [profile, orgs] = await Promise.all([
    fetch(`${conditions.apiBase}/users/${event.payload.userId}`, { signal }).then((r) => r.json()),
    fetch(`${conditions.apiBase}/users/${event.payload.userId}/orgs`, { signal }).then((r) => r.json()),
  ]);
  signal.throwIfAborted();
  const billing = await fetch(`${conditions.apiBase}/orgs/${orgs[0]?.id}/billing`, { signal }).then((r) =>
    r.json(),
  );
  signal.throwIfAborted();

  actions.setProfile?.(profile);
  actions.setOrgs?.(orgs);
  actions.setBilling?.(billing);
  actions.markReady?.();
}

Два трейдоффа: первая версия раньше показывает частичный прогресс (выигрыш UX); вторая атомарная, но кажется медленнее. Выбирай по сценарию.

Распространённая ловушка: забыли передать signal в fetch

Заголовок раздела «Распространённая ловушка: забыли передать signal в fetch»

Самый частый async-баг. Обработчик выглядит правильно, типы компилируются, happy path работает — а потом под нагрузкой пользователь видит устаревшие результаты от запроса, который должен был быть отменён три нажатия клавиш назад.

// ✗ Сломано — работает, но течёт.
async handler({ event, actions }) {
  const res = await fetch(`/search?q=${event.payload.q}`);
  actions.show?.(await res.json());
}
// ✓ Починено.
async handler({ event, signal, actions }) {
  const res = await fetch(`/search?q=${event.payload.q}`, { signal });
  signal.throwIfAborted();
  actions.show?.(await res.json());
}

Что ты увидишь со сломанной версией под take-latest:

  • 5 нажатий → 5 сетевых запросов, все в полёте.
  • Рантайм прерывает первые 4 обработчика через signal.aborted = true, но fetch-вызовы сигнал не видят — они продолжают.
  • Все 5 await fetch(...) в итоге резолвятся. Первые 4 кинут AbortError из signal.throwIfAborted(), если он есть. Без него все 5 зовут actions.show в порядке прибытия, не в порядке нажатий клавиш.
  • Пользователь видит мерцающие результаты, иногда заканчивающиеся не на том запросе.

@triggery/eslint-plugin поставляет opt-in правило (fetch-signal), которое флагает вызовы fetch() внутри async-обработчиков без { signal }.

Распространённая ловушка: ловить AbortError и продолжать

Заголовок раздела «Распространённая ловушка: ловить AbortError и продолжать»
async handler({ event, signal, actions }) {
  try {
    const res = await fetch(event.payload.url, { signal });
    actions.show?.(await res.json());
  } catch (err) {
    // ✗ Глотает abort — превращает чистую отмену в шумный путь ошибки.
    console.error('failed', err);
    actions.showError?.({ message: 'failed' });
  }
}

AbortError — это не ошибка в твоём сценарии, это значит, что рантайм отменил прогон. Перекинь её, чтобы рантайм классифицировал прогон как 'aborted', а не 'errored':

async handler({ event, signal, actions }) {
  try {
    const res = await fetch(event.payload.url, { signal });
    actions.show?.(await res.json());
  } catch (err) {
    if (signal.aborted) throw err;             // пусть рантайм разберётся
    actions.showError?.({ message: String(err) });
  }
}