У сучасних високонавантажених фронтенд-системах та мікросервісах Node.js управління асинхронними операціями давно вийшло за межі простих ланцюжків Promise.all(). У реальних розподілених архітектурах інженерам необхідно вирішувати критичні задачі:
Стандарт веб-платформи AbortController / AbortSignal став універсальним протоколом кооперативного скасування завдань. У поєднанні з інтерфейсами AsyncIterable<T> та патерном Communicating Sequential Processes (CSP / Go-подібні канали) TypeScript дозволяє будувати математично строгі, типобезпечні конвеєри конкурентної обробки даних із нульовим ризиком необроблених винятків чи гонитви станів.
+-------------------------------------------------------------------------+
| АРХІТЕКТУРА КОНКУРЕНТНОГО КОНВЕЄРА |
+-------------------------------------------------------------------------+
| [Producer (SSE / WebSocket / LLM Stream)] |
| |--> Породжує потік повідомлень: AsyncIterable<ServerPacket> |
+-------------------------------------------------------------------------+
|
v (Backpressure Queue / Channel<T>)
+-------------------------------------------------------------------------+
| [Типобезпечний буферизований канал: Channel<ServerPacket>] |
| |-- push(packet: T): Promise<void> (Блокується при переповненні) |
| |-- pop(signal?: AbortSignal): Promise<T> (Блокується при спустошенні)|
| `-- close(): void |
+-------------------------------------------------------------------------+
|
v (Worker Pool: Concurrency Limit = 3)
+-------------------------------------------------------------------------+
| [Паралельний пул споживачів (Consumers / Workers)] |
| |-- Worker 1: Обробка пакета A (AbortSignal.timeout(5000)) |
| |-- Worker 2: Обробка пакета B (Переривання через abort()) |
| `-- Worker 3: Обробка пакета C (Успішна фіналізація) |
+-------------------------------------------------------------------------+Конкурентна обробка в TypeScript спирається на строгі абстракції скасування та обміну повідомленнями:
Композиція сигналів скасування:
[Сигнал тайм-ауту: AbortSignal.timeout(5000)] -----\
+--> [AbortSignal.any([...])] --> fetch(url, { signal })
[Сигнал кнопки UI: userCancelController.signal] ---/
Запит переривається, якщо минуло 5 секунд АБО якщо користувач натиснув «Скасувати»!AsyncIterable<string> з можливістю миттєвого скидання контексту кнопкою «Stop generating».concurrencyLimit = 4) для захисту сокетів та мережевого стеку від вичерпання дескрипторів.AbortSignal.timeout() та автоматичним перемиканням на резервні вузли (Failover).AbortSignal.any() та .timeout()#У сучасному TypeScript (Node.js 20+ та сучасні браузери) створення та поєднання таймаутів спростилося завдяки статичним методам AbortSignal:
export interface FetchWithTimeoutOptions extends RequestInit {
readonly timeoutMs?: number;
readonly externalSignal?: AbortSignal;
}
/**
* Виконує стійкий мережевий запит із композитним скасуванням:
* за таймаутом АБО за зовнішнім сигналом.
*/
export async function fetchWithResilience<TResponse>(
url: string,
options: FetchWithTimeoutOptions = {}
): Promise<TResponse> {
const { timeoutMs = 8000, externalSignal, ...fetchOptions } = options;
// 1. Створюємо нативний сигнал таймауту
const timeoutSignal = AbortSignal.timeout(timeoutMs);
// 2. Об'єднуємо таймаут і зовнішній сигнал через AbortSignal.any
const combinedSignal = externalSignal
? AbortSignal.any([timeoutSignal, externalSignal])
: timeoutSignal;
try {
const response = await fetch(url, {
...fetchOptions,
signal: combinedSignal,
});
if (!response.ok) {
throw new Error(`[HttpError] HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as TResponse;
} catch (error: unknown) {
if (combinedSignal.aborted) {
// Розрізняємо причину переривання: таймаут чи дія користувача
if (timeoutSignal.aborted) {
throw new Error(`[TimeoutError] Запит до ${url} перевищив ліміт у ${timeoutMs}ms`);
}
throw new Error(`[AbortError] Запит до ${url} було скасовано викликаючою стороною`);
}
throw error instanceof Error ? error : new Error(String(error));
}
}Реалізуємо асинхронний канал для обміну даними між паралельними процесами:
interface Resolver<T> {
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
/**
* Типобезпечний асинхронний канал передачі даних (Communicating Sequential Processes).
*/
export class AsyncChannel<T> implements AsyncIterable<T> {
private readonly buffer: T[] = [];
private readonly receivers: Resolver<T>[] = [];
private isClosed = false;
constructor(private readonly maxCapacity: number = Infinity) {}
/**
* Запис елемента в канал.
*/
public push(item: T): boolean {
if (this.isClosed) {
throw new Error('[ChannelError] Спроба запису в закритий канал');
}
// Якщо є очікуючі споживачі — віддаємо першому в черзі
if (this.receivers.length > 0) {
const receiver = this.receivers.shift()!;
receiver.resolve(item);
return true;
}
// Перевірка ліміту буфера
if (this.buffer.length >= this.maxCapacity) {
// Буфер переповнено: витісняємо найстаріший елемент (Drop-oldest backpressure)
this.buffer.shift();
}
this.buffer.push(item);
return true;
}
/**
* Асинхронне читання елемента з каналу з підтримкою скасування.
*/
public async pop(signal?: AbortSignal): Promise<T | null> {
if (this.buffer.length > 0) {
return this.buffer.shift()!;
}
if (this.isClosed) {
return null;
}
return new Promise<T | null>((resolve, reject) => {
const resolver: Resolver<T> = {
resolve: (val) => {
cleanUp();
resolve(val);
},
reject: (err) => {
cleanUp();
reject(err);
},
};
const onAbort = () => {
const index = this.receivers.indexOf(resolver);
if (index > -1) {
this.receivers.splice(index, 1);
}
cleanUp();
reject(new Error('[AbortError] Очікування елемента з каналу перервано сигналом'));
};
const cleanUp = () => {
signal?.removeEventListener('abort', onAbort);
};
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener('abort', onAbort);
this.receivers.push(resolver);
});
}
public close(): void {
this.isClosed = true;
// Сповіщаємо всіх очікуючих читачів про закриття каналу
while (this.receivers.length > 0) {
const receiver = this.receivers.shift()!;
receiver.resolve(null as unknown as T);
}
}
/**
* Реалізація протоколу AsyncIterable для ітерації через цикл for await (... of ...)
*/
public async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
const value = await this.pop();
if (value === null && this.isClosed) {
break;
}
if (value !== null) {
yield value;
}
}
}
}export type AsyncTask<T> = (signal: AbortSignal) => Promise<T>;
/**
* Обмежує кількість одночасно виконуваних асинхронних завдань.
*/
export class ConcurrencyPool {
private activeCount = 0;
private readonly queue: Array<() => void> = [];
constructor(public readonly concurrencyLimit: number) {
if (concurrencyLimit < 1) {
throw new RangeError('Ліміт паралелізму має бути >= 1');
}
}
/**
* Запускає завдання з дотриманням пулу паралелізму та контролем скасування.
*/
public run<T>(task: AsyncTask<T>, signal?: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const execute = async () => {
if (signal?.aborted) {
this.next();
reject(new Error('[AbortError] Завдання скасовано до початку виконання'));
return;
}
this.activeCount++;
try {
// Створюємо локальний контролер, пов'язаний із зовнішнім сигналом
const ctrl = new AbortController();
const onAbort = () => ctrl.abort();
signal?.addEventListener('abort', onAbort);
const result = await task(ctrl.signal);
signal?.removeEventListener('abort', onAbort);
resolve(result);
} catch (err: unknown) {
reject(err);
} finally {
this.activeCount--;
this.next();
}
};
if (this.activeCount < this.concurrencyLimit) {
void execute();
} else {
this.queue.push(execute);
}
});
}
private next(): void {
if (this.queue.length > 0 && this.activeCount < this.concurrencyLimit) {
const nextTask = this.queue.shift();
nextTask?.();
}
}
}Завдання надходять до пулу. Якщо activeCount < concurrencyLimit, задача стартує негайно; інакше вона очікує в черзі.
Сигнал таймауту та сигнал користувача зв'язуються через AbortSignal.any(), формуючи суцільний ланцюг скасування.
Мережева операція виконується з прокиданням signal. У разі скасування сокет або HTTP-стрім закриваються на рівні ядра ОС.
У блоці finally лічильник зменшується (activeCount--), і диспетчер витягує наступне завдання з масиву очікування.
export interface SseEvent<TData> {
readonly id?: string;
readonly event: string;
readonly data: TData;
readonly timestamp: number;
}
/**
* Читає потік Server-Sent Events у вигляді типобезпечного асинхронного генератора.
*/
export async function* streamSseEvents<TData>(
url: string,
signal?: AbortSignal
): AsyncIterable<SseEvent<TData>> {
const response = await fetch(url, {
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
signal,
});
if (!response.ok || !response.body) {
throw new Error(`[SseError] Не вдалося встановити з'єднання: HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
if (signal?.aborted) {
break;
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() ?? '';
for (const block of lines) {
if (!block.trim()) continue;
let eventType = 'message';
let eventDataRaw = '';
let eventId: string | undefined;
for (const line of block.split('\n')) {
if (line.startsWith('event:')) {
eventType = line.replace('event:', '').trim();
} else if (line.startsWith('data:')) {
eventDataRaw += line.replace('data:', '').trim();
} else if (line.startsWith('id:')) {
eventId = line.replace('id:', '').trim();
}
}
if (eventDataRaw) {
const parsedData = JSON.parse(eventDataRaw) as TData;
yield {
id: eventId,
event: eventType,
data: parsedData,
timestamp: Date.now(),
};
}
}
}
} finally {
reader.releaseLock();
}
}Прийняття аргументу signal?: AbortSignal у сигнатурі без додавання слухача події addEventListener('abort', ...) або передачі в нативні API робить сигнал абсолютно неефективним: функція продовжуватиме виконуватися до кінця.
// ❌ АНТИПАТЕРН: Аргумент signal приймається, але не прослуховується!
async function sleepBad(ms: number, signal?: AbortSignal) {
return new Promise((resolve) => setTimeout(resolve, ms)); // Сигнал ігнорується!
}
// ✅ ПРАВИЛЬНО: Підписка на скасування та очищення таймера
async function sleepGood(ms: number, signal?: AbortSignal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error('Aborted'));
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(new Error('Aborted'));
}, { once: true });
});
}Прикріплення обробників до довгоживучих екземплярів AbortSignal без наступного очищення через removeEventListener або прапорець { once: true } утримує замикання та об'єкти пам'яті на тисячах запитів.
// ❌ АНТИПАТЕРН: Накопичення слухачів на глобальному сигналі
signal.addEventListener('abort', onAbort); // ВИТІК ПАМ'ЯТІ!
// ✅ ПРАВИЛЬНО: Використання опції once або явне очищення
signal.addEventListener('abort', onAbort, { once: true });Під час скасування нативного fetch викидається виняток із error.name === 'AbortError'. Якщо застосунок не відфільтровує цю штатну подію, системи моніторингу помилок переповнюються хибними сповіщеннями.
// ✅ ПРАВИЛЬНО: Відділення штатного переривання від реальних мережевих збоїв
try {
await fetch(url, { signal });
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
// Штатне скасування — ігноруємо або логуємо на рівні debug
return;
}
console.error('Реальний мережевий збій:', err);
}Якщо асинхронний генератор не перевіряє умови завершення або канал не викликає close(), цикл for await назавжди залишиться заблокованим в очікуванні наступної події.
// ✅ ПРАВИЛЬНО: Обов'язковий виклик channel.close() у блоці finally продюсераПроаналізуйте пропускну здатність зовнішніх API або пулу з'єднань БД і встановіть concurrencyLimit (зазвичай 4-8).
Застосовуйте AbortSignal.timeout() для запобігання зависанням і пов'язуйте його з UI-сигналом через AbortSignal.any().
Створіть екземпляр AsyncChannel<T> із контрольованим розміром буфера та стратегією витіснення (Backpressure).
Запустіть обробку через ConcurrencyPool, гарантуючи обов'язкове прокидання сигналу скасування в кожну таску.
Переконайтеся у виклику reader.releaseLock(), channel.close() та видаленні слухачів подій у блоках finally.