AbortController в Node.js: отмена чего угодно. abortcontroller.. abortcontroller. abortsignal.. abortcontroller. abortsignal. JavaScript.. abortcontroller. abortsignal. JavaScript. Node.JS.. abortcontroller. abortsignal. JavaScript. Node.JS. nodejs.. abortcontroller. abortsignal. JavaScript. Node.JS. nodejs. асинхронный код.. abortcontroller. abortsignal. JavaScript. Node.JS. nodejs. асинхронный код. Блог компании OTUS.. abortcontroller. abortsignal. JavaScript. Node.JS. nodejs. асинхронный код. Блог компании OTUS. Программирование.. abortcontroller. abortsignal. JavaScript. Node.JS. nodejs. асинхронный код. Блог компании OTUS. Программирование. серверная разработка.

У Node.js исторически была проблема с отменой операций. Запустил HTTP‑запрос — жди, пока не ответит или не упадёт по таймауту. Читаешь огромный файл — читай до конца. Запустил пачку промисов — сиди, смотри, как они доедают ресурсы. Механизма сказать «стоп, хватит» в языке просто не было. Кто‑то мастерил свои костыли на флагах, кто‑то использовал библиотеки вроде p-cancelable, но единого стандарта не существовало.

AbortController эту проблему решает. Пришёл он из браузерного API (там его придумали для отмены fetch), но в Node.js прижился настолько, что теперь поддерживается почти везде: fetch, fs, stream, child_process, setTimeout, EventEmitter, встроенный тест‑раннер.

Разберём, как он устроен, и где вообще полезен.

Механика: контроллер и сигнал

Вся идея укладывается в два объекта.

AbortController — тот, кто отменяет. AbortSignal — тот, кто слушает отмену. Контроллер создаёт сигнал, вы передаёте сигнал в операцию, а когда нужно отменить — дёргаете abort() на контроллере.

const controller = new AbortController();
const signal = controller.signal;

// Подписываемся на отмену
signal.addEventListener('abort', () => {
    console.log('Отменено!');
    console.log(signal.reason); // причина отмены
});

console.log(signal.aborted); // false — пока не отменён

controller.abort('Пользователь нажал Отмена');
// → Отменено!
// → Пользователь нажал Отмена

console.log(signal.aborted); // true — уже отменён

Контроллер одноразовый. Один abort() — и всё. Повторно вызвать можно, но ничего не произойдёт — сигнал уже сработал. Если нужна новая операция с возможностью отмены — создавайте новый контроллер.

Аргумент abort() — это reason. Может быть строкой, ошибкой, чем угодно. Если не передать — по умолчанию будет DOMException с именем AbortError.

fetch: таймаут без библиотек

Самый частый кейс — ограничить время HTTP‑запроса. До AbortController это выглядело так: Promise.race([fetch(url), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]). Без комментариев.

Теперь:

// Способ 1: AbortSignal.timeout() — самый простой
try {
    const response = await fetch('https://api.example.com/data', {
        signal: AbortSignal.timeout(5000) // 5 секунд и всё
    });
    const data = await response.json();
} catch (err) {
    if (err.name === 'TimeoutError') {
        console.log('Таймаут — сервер не ответил за 5 секунд');
    }
}

AbortSignal.timeout() — статический метод, который создаёт сигнал с автоматическим таймаутом. Не нужен контроллер, не нужен setTimeout, не нужен clearTimeout. Одна строка.

Но если нужна отмена по условию (а не только по времени), нужен ручной контроллер:

// Способ 2: ручной контроллер — отмена по кнопке/событию/условию
const controller = new AbortController();

// Таймаут вручную
const timerId = setTimeout(() => controller.abort('timeout'), 5000);

try {
    const response = await fetch('https://api.example.com/data', {
        signal: controller.signal
    });
    clearTimeout(timerId); // Ответ пришёл — таймер не нужен
    return await response.json();
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Запрос отменён:', controller.signal.reason);
    } else {
        throw err; // Сетевая ошибка или что-то другое
    }
}

AbortSignal.timeout() бросает TimeoutError, а ручной abort() без аргумента — AbortError. Если вам нужно различать «пользователь отменил» и «сервер не ответил» — используйте ручной контроллер для первого и timeout() для второго. Или передавайте разные reason в abort().

AbortSignal.any(): комбинируем условия отмены

Реальная задача: запрос должен отмениться, если прошло 5 секунд, ИЛИ если пользователь нажал «Отмена», ИЛИ если компонент размонтировался. Три причины, один fetch.

AbortSignal.any() объединяет несколько сигналов в один:

const userController = new AbortController();
const cleanupController = new AbortController();

// Отменится по ЛЮБОМУ из трёх условий
const signal = AbortSignal.any([
    AbortSignal.timeout(5000),          // таймаут
    userController.signal,               // пользователь нажал Отмена
    cleanupController.signal,            // компонент размонтировался
]);

try {
    const response = await fetch(url, { signal });
    const data = await response.json();
} catch (err) {
    // signal.reason покажет причину того сигнала, который сработал первым
    console.log('Отменено:', signal.reason);
}

// Где-то в обработчике кнопки:
userController.abort('User cancelled');

// Где-то при размонтировании:
cleanupController.abort('Component unmounted');

Сработает тот сигнал, который придёт первым. Остальные игнорируются — результирующий сигнал уже aborted.

Файловая система

fs/promises поддерживает signal в readFile, writeFile, open, watch:

import { readFile, writeFile } from 'node:fs/promises';

const controller = new AbortController();

// Где-то может прийти отмена
setTimeout(() => controller.abort(), 3000);

try {
    const data = await readFile('/path/to/huge-file.csv', {
        signal: controller.signal,
        encoding: 'utf-8',
    });
    console.log(`Прочитано ${data.length} символов`);
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Чтение файла отменено — не успели за 3 секунды');
    } else {
        throw err;
    }
}

Для writeFile — то же самое. Если отмена пришла до завершения записи, файл может быть записан частично. Учитывайте это: пишите во временный файл, потом переименовывайте.

Стримовый API (createReadStream, createWriteStream) не принимает signal напрямую. Там нужно закрывать стрим вручную или использовать pipeline() из stream/promises, который signal поддерживает:

import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';

await pipeline(
    createReadStream('input.csv'),
    createWriteStream('output.csv'),
    { signal: AbortSignal.timeout(10000) }
);

Промис‑таймеры

Промис‑версии таймеров из node:timers/promises тоже поддерживают сигнал:

import { setTimeout as delay } from 'node:timers/promises';

const controller = new AbortController();

try {
    // Ждём 10 секунд, но можем отменить раньше
    const result = await delay(10000, 'готово', {
        signal: controller.signal
    });
    console.log(result); // 'готово' — если дождались
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Не дождались, отменили');
    }
}

Удобно для поллинга, ретраев, любых «подождать, но с возможностью прервать».

EventEmitter: автоматическая отписка

Малоизвестная, но очень полезная штучка. on() на EventEmitter принимает signal для автоматической отписки:

import { EventEmitter } from 'node:events';

const emitter = new EventEmitter();
const controller = new AbortController();

emitter.on('data', (chunk) => {
    console.log('Получено:', chunk);
}, { signal: controller.signal });

emitter.emit('data', 'первый');   // → Получено: первый
emitter.emit('data', 'второй');   // → Получено: второй

controller.abort();

emitter.emit('data', 'третий');   // → тишина, слушатель отписан

Ещё один кейс — events.on() (async итератор по событиям):

import { on } from 'node:events';

const controller = new AbortController();

// Асинхронный итератор по событиям — с возможностью остановки
setTimeout(() => controller.abort(), 5000);

try {
    for await (const [data] of on(emitter, 'data', { signal: controller.signal })) {
        console.log(data);
        if (data === 'stop') controller.abort();
    }
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Итерация остановлена');
    }
}

Отмена при обрыве HTTP‑соединения

Клиент может закрыть вкладку или оборвать соединение. Если вы в этот момент ждёте ответ от тяжёлого внешнего API или считаете отчёт — зачем продолжать? Получатель ушёл.

// Express
app.get('/api/report', async (req, res) => {
    const controller = new AbortController();

    // Клиент закрыл соединение — отменяем всё downstream
    req.on('close', () => controller.abort('Client disconnected'));

    try {
        // Тяжёлый запрос к внешнему API
        const raw = await fetch('https://analytics.internal/heavy-report', {
            signal: controller.signal,
        });
        const report = await raw.json();

        // Тяжёлая обработка — тоже проверяем отмену
        controller.signal.throwIfAborted();

        const processed = processReport(report);
        res.json(processed);
    } catch (err) {
        if (err.name === 'AbortError') {
            // Клиент ушёл — ответ отправлять некому, просто выходим
            return;
        }
        res.status(500).json({ error: 'Internal error' });
    }
});

Паттерн экономит серверные ресурсы.

Свой код с поддержкой AbortSignal

Если пишете библиотеку или утилиту, добавить поддержку отмены несложно. Просто принимаете signal в options, проверяете throwIfAborted() перед каждым шагом, передаёте signal во все вложенные операции.

async function pollUntilReady(url, { signal, interval = 2000 } = {}) {
    while (true) {
        // Проверяем перед каждой итерацией
        signal?.throwIfAborted();

        try {
            const res = await fetch(url, { signal });
            const data = await res.json();

            if (data.status === 'ready') {
                return data;
            }
        } catch (err) {
            // Если отмена — пробрасываем наверх
            if (err.name === 'AbortError') throw err;
            // Если сетевая ошибка — пробуем ещё раз
            console.log('Retry after error:', err.message);
        }

        // Пауза между попытками — тоже отменяемая
        await new Promise((resolve, reject) => {
            const timer = globalThis.setTimeout(resolve, interval);
            signal?.addEventListener('abort', () => {
                clearTimeout(timer);
                reject(signal.reason);
            }, { once: true });
        });
    }
}

// Использование: поллинг с таймаутом 30 секунд
const result = await pollUntilReady('https://api.example.com/job/123', {
    signal: AbortSignal.timeout(30000),
    interval: 3000,
});

signal.throwIfAborted() — бросает исключение, если сигнал уже сработал. Вызывайте в начале каждого шага, чтобы не делать лишнюю работу.

Отменяемый sleep — настолько частый паттерн, что имеет смысл вынести в утилиту:

function abortableSleep(ms, { signal } = {}) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(resolve, ms);
        signal?.addEventListener('abort', () => {
            clearTimeout(timer);
            reject(signal.reason);
        }, { once: true });
    });
}

Некоторые ошибки

  • Переиспользование контроллера. Один контроллер — одна логическая операция. Отменили — создали новый. Если повесить один контроллер на цикл запросов и вызвать abort() — отменятся все разом, включая будущие.

  • Забыли обработать AbortError. Без try/catch отмена уронит процесс. Всегда проверяйте err.name === 'AbortError' и решайте — это штатная ситуация или нет.

  • AbortController на синхронном коде. JSON‑парсинг, сортировка массива, валидация — отменять нечего. AbortController для async‑операций.

  • Утечка таймеров. Если создали setTimeout для ручного abort — не забудьте clearTimeout при успехе. Иначе таймер сработает уже после завершения операции.

AbortController в Node.js: отмена чего угодно - 1

Если хочется не просто писать серверный код на JavaScript, а уверенно разбираться в архитектуре Node.js-приложений, здесь нужна не подборка приёмов, а системная практика. На курсе «Node.js разработчик» как раз дают TypeScript, базы данных, тестирование и ключевые инструменты бэкенд-разработки в связке.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:

  • 26 марта в 20:00. «NestJS и архитектура масштабируемых серверных приложений на Node.js». Записаться

  • 9 апреля в 20:00. «Работа в реальном времени на Node.js и TypeScript: создаём WebSocket-чат и разбираем архитектуру серверной части». Записаться

  • 22 апреля в 20:00. «Bun + искусственный интеллект: создаём быстрый сервер нового поколения на JavaScript». Записаться

Автор: badcasedaily1

Источник

Rambler's Top100